Browse Source

Feature/elastic (#488)

* Temporary

* More progress.

* Updates to text idnexer.

* Some progress.

* Test

* Upgrade to NG9

* Build fixed.

* Tsconfig udpated

* Progress

* Small build optimization.

* Improvements

* Fixes

* Elastic search indexer.

* Text indexer improvement.

* Text indexer improvements.

* Impróvements for singleton content items.

* Found

* Started with tests.

* More tests.

* More tests.

* Better text index.

* Handle asset search.

* Remove unused status filter.

* Tests fixed.

* More tests.

* Small task fix.

* Loading animations.

* Extracted scripting.

* Fix formatting.

* Performance improvement and migration fix.
pull/492/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
fdac7beeaa
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs
  2. 25
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs
  3. 2
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  4. 12
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs
  5. 7
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs
  6. 135
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs
  7. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  8. 51
      backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
  9. 144
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  10. 119
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs
  11. 55
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  12. 119
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  13. 87
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  14. 19
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs
  15. 13
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs
  16. 7
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContent.cs
  17. 14
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs
  18. 30
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs
  19. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs
  20. 1
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduledContents.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoIndexStorage.cs
  22. 61
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs
  23. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs
  24. 10
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs
  25. 92
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs
  26. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  27. 60
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs
  28. 81
      backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs
  29. 45
      backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs
  30. 21
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs
  31. 7
      backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetFolderState.cs
  32. 17
      backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs
  33. 7
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs
  34. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs
  35. 16
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs
  36. 3
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs
  37. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs
  38. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContentDraft.cs
  39. 13
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContentDraft.cs
  40. 11
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  41. 153
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs
  42. 24
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  43. 11
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs
  44. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  45. 163
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs
  46. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs
  47. 22
      backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  48. 30
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  49. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  50. 56
      backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  51. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs
  52. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  53. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs
  54. 21
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  55. 98
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  56. 19
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs
  57. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs
  58. 34
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
  59. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  60. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  61. 60
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
  62. 11
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  63. 15
      backend/src/Squidex.Domain.Apps.Entities/Contents/SearchScope.cs
  64. 115
      backend/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  65. 38
      backend/src/Squidex.Domain.Apps.Entities/Contents/State/ContentVersion.cs
  66. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/DeleteIndexEntry.cs
  67. 198
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs
  68. 54
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs
  69. 117
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs
  70. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IContentTextIndex.cs
  71. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexCommand.cs
  72. 114
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexState.cs
  73. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IIndex.cs
  74. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/ILuceneTextIndexGrain.cs
  75. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs
  76. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs
  77. 53
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneExtensions.cs
  78. 61
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndex.cs
  79. 253
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndexGrain.cs
  80. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/MultiLanguageAnalyzer.cs
  81. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/AssetIndexStorage.cs
  82. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/FileIndexStorage.cs
  83. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/IIndexStorage.cs
  84. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchContext.cs
  85. 46
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs
  86. 64
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs
  87. 23
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/ITextIndexerState.cs
  88. 50
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/InMemoryTextIndexerState.cs
  89. 46
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/TextContentState.cs
  90. 172
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexContent.cs
  91. 181
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexerGrain.cs
  92. 277
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  93. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs
  94. 12
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs
  95. 28
      backend/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs
  96. 3
      backend/src/Squidex.Domain.Apps.Entities/FodyWeavers.xml
  97. 26
      backend/src/Squidex.Domain.Apps.Entities/FodyWeavers.xsd
  98. 8
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs
  99. 7
      backend/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs
  100. 8
      backend/src/Squidex.Domain.Apps.Entities/Q.cs

6
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs

@ -34,12 +34,6 @@ namespace Squidex.Extensions.Actions.ElasticSearch
[Formattable]
public string IndexName { get; set; }
[Required]
[Display(Name = "Index Type", Description = "The name of the index type.")]
[DataType(DataType.Text)]
[Formattable]
public string IndexType { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[DataType(DataType.Text)]
public string Username { get; set; }

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

@ -43,11 +43,13 @@ namespace Squidex.Extensions.Actions.ElasticSearch
var contentId = contentEvent.Id.ToString();
var ruleDescription = string.Empty;
var ruleJob = new ElasticSearchJob
{
Host = action.Host.ToString(),
IndexName = Format(action.IndexName, @event),
IndexType = Format(action.IndexType, @event),
ServerHost = action.Host.ToString(),
ServerUser = action.Username,
ServerPassword = action.Password,
ContentId = contentId
};
@ -65,9 +67,6 @@ namespace Squidex.Extensions.Actions.ElasticSearch
ruleJob.Content = $"{{ \"objectId\": \"{contentId}\", {json.Substring(1)}";
}
ruleJob.Username = action.Username;
ruleJob.Password = action.Password;
return (ruleDescription, ruleJob);
}
@ -76,24 +75,24 @@ namespace Squidex.Extensions.Actions.ElasticSearch
protected override async Task<Result> ExecuteJobAsync(ElasticSearchJob job, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.Host))
if (string.IsNullOrWhiteSpace(job.ServerHost))
{
return Result.Ignored();
}
var client = clients.GetClient((new Uri(job.Host, UriKind.Absolute), job.Username, job.Password));
var client = clients.GetClient((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword));
try
{
if (job.Content != null)
{
var response = await client.IndexAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId, job.Content, ctx: ct);
var response = await client.IndexAsync<StringResponse>(job.IndexName, job.ContentId, job.Content, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
else
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId, ctx: ct);
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
@ -107,18 +106,16 @@ namespace Squidex.Extensions.Actions.ElasticSearch
public sealed class ElasticSearchJob
{
public string Host { get; set; }
public string ServerHost { get; set; }
public string Username { get; set; }
public string ServerUser { get; set; }
public string Password { get; set; }
public string ServerPassword { get; set; }
public string ContentId { get; set; }
public string Content { get; set; }
public string IndexName { get; set; }
public string IndexType { get; set; }
}
}

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

@ -12,7 +12,7 @@
<PackageReference Include="Confluent.Kafka" Version="1.3.0" />
<PackageReference Include="CoreTweet" Version="1.0.0.483" />
<PackageReference Include="Datadog.Trace" Version="1.11.0" />
<PackageReference Include="Elasticsearch.Net" Version="6.8.1" />
<PackageReference Include="Elasticsearch.Net" Version="7.5.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.1.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.3" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />

12
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs

@ -25,17 +25,5 @@ namespace Squidex.Domain.Apps.Core.Apps
PlanId = planId;
}
public static AppPlan? Build(RefToken owner, string planId)
{
if (planId == null)
{
return null;
}
else
{
return new AppPlan(owner, planId);
}
}
}
}

7
backend/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowCondition.cs

@ -21,12 +21,5 @@ namespace Squidex.Domain.Apps.Core.Contents
Roles = roles;
}
public override string ToString()
{
var roles = Roles?.Count > 0 ? string.Join(", ", Roles) : "*";
return $"When=${Expression}, For={roles}";
}
}
}

135
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs

@ -0,0 +1,135 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.ConvertContent
{
public sealed class StringFormatter : IFieldVisitor<string>
{
private readonly IJsonValue value;
private StringFormatter(IJsonValue value)
{
this.value = value;
}
public static string Format(IJsonValue? value, IField field)
{
Guard.NotNull(field);
if (value == null || value is JsonNull)
{
return string.Empty;
}
return field.Accept(new StringFormatter(value));
}
public string Visit(IArrayField field)
{
return FormatArray("Item", "Items");
}
public string Visit(IField<AssetsFieldProperties> field)
{
return FormatArray("Asset", "Assets");
}
public string Visit(IField<BooleanFieldProperties> field)
{
if (value is JsonBoolean boolean && boolean.Value)
{
return "Yes";
}
else
{
return "No";
}
}
public string Visit(IField<DateTimeFieldProperties> field)
{
return value.ToString();
}
public string Visit(IField<GeolocationFieldProperties> field)
{
if (value is JsonObject obj && obj.TryGetValue("latitude", out var lat) && obj.TryGetValue("longitude", out var lon))
{
return $"{lat}, {lon}";
}
else
{
return string.Empty;
}
}
public string Visit(IField<JsonFieldProperties> field)
{
return "<Json />";
}
public string Visit(IField<NumberFieldProperties> field)
{
return value.ToString();
}
public string Visit(IField<ReferencesFieldProperties> field)
{
return FormatArray("Reference", "References");
}
public string Visit(IField<StringFieldProperties> field)
{
if (field.Properties.Editor == StringFieldEditor.StockPhoto)
{
return "[Photo]";
}
else
{
return value.ToString();
}
}
public string Visit(IField<TagsFieldProperties> field)
{
if (value is JsonArray array)
{
return string.Join(", ", array);
}
else
{
return string.Empty;
}
}
public string Visit(IField<UIFieldProperties> field)
{
return string.Empty;
}
private string FormatArray(string singularName, string pluralName)
{
if (value is JsonArray array)
{
if (array.Count > 1)
{
return $"{array.Count} {pluralName}";
}
else if (array.Count == 1)
{
return $"1 {singularName}";
}
}
return $"0 {pluralName}";
}
}
}

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

@ -32,10 +32,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules
private static readonly Regex ContentDataPlaceholderNew = new Regex(@"^\{CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}\}", 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 IRuleUrlGenerator urlGenerator;
private readonly IUrlGenerator urlGenerator;
private readonly IScriptEngine scriptEngine;
public RuleEventFormatter(IJsonSerializer jsonSerializer, IRuleUrlGenerator urlGenerator, IScriptEngine scriptEngine)
public RuleEventFormatter(IJsonSerializer jsonSerializer, IUrlGenerator urlGenerator, IScriptEngine scriptEngine)
{
Guard.NotNull(jsonSerializer);
Guard.NotNull(scriptEngine);
@ -233,7 +233,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
if (@event is EnrichedContentEvent contentEvent)
{
return urlGenerator.GenerateContentUIUrl(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id);
return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id);
}
return Fallback;

51
backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs

@ -0,0 +1,51 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core
{
public interface IUrlGenerator
{
string AppSettingsUI(NamedId<Guid> appId);
string AssetsUI(NamedId<Guid> appId);
string AssetsUI(NamedId<Guid> appId, string? query = null);
string BackupsUI(NamedId<Guid> appId);
string ClientsUI(NamedId<Guid> appId);
string ContentsUI(NamedId<Guid> appId);
string ContentsUI(NamedId<Guid> appId, NamedId<Guid> schemaId);
string ContentUI(NamedId<Guid> appId, NamedId<Guid> schemaId, Guid contentId);
string ContributorsUI(NamedId<Guid> appId);
string DashboardUI(NamedId<Guid> appId);
string LanguagesUI(NamedId<Guid> appId);
string PatternsUI(NamedId<Guid> appId);
string PlansUI(NamedId<Guid> appId);
string RolesUI(NamedId<Guid> appId);
string RulesUI(NamedId<Guid> appId);
string SchemasUI(NamedId<Guid> appId);
string SchemaUI(NamedId<Guid> appId, NamedId<Guid> schemaId);
string WorkflowsUI(NamedId<Guid> appId);
}
}

144
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs

@ -0,0 +1,144 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public sealed class MongoContentCollectionAll : MongoRepositoryBase<MongoContentEntity>
{
private readonly QueryContent queryContentAsync;
private readonly QueryContentsByIds queryContentsById;
private readonly QueryContentsByQuery queryContentsByQuery;
private readonly QueryIdsAsync queryIdsAsync;
private readonly QueryScheduledContents queryScheduledItems;
public MongoContentCollectionAll(IMongoDatabase database, IAppProvider appProvider, IContentTextIndex indexer, IJsonSerializer serializer)
: base(database)
{
queryContentAsync = new QueryContent(serializer);
queryContentsById = new QueryContentsByIds(serializer, appProvider);
queryContentsByQuery = new QueryContentsByQuery(serializer, indexer);
queryIdsAsync = new QueryIdsAsync(appProvider);
queryScheduledItems = new QueryScheduledContents();
}
public IMongoCollection<MongoContentEntity> GetInternalCollection()
{
return Collection;
}
protected override string CollectionName()
{
return "State_Contents_All";
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default)
{
await queryContentAsync.PrepareAsync(collection, ct);
await queryContentsById.PrepareAsync(collection, ct);
await queryContentsByQuery.PrepareAsync(collection, ct);
await queryIdsAsync.PrepareAsync(collection, ct);
await queryScheduledItems.PrepareAsync(collection, ct);
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query)
{
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
{
return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.All);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids)
{
Guard.NotNull(app);
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
{
var result = await queryContentsById.DoAsync(app.Id, schema, ids);
return ResultList.Create(result.Count, result.Select(x => x.Content));
}
}
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<Guid> ids)
{
Guard.NotNull(app);
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
{
var result = await queryContentsById.DoAsync(app.Id, null, ids);
return result;
}
}
public async Task<IContentEntity?> FindContentAsync(ISchemaEntity schema, Guid id)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryContentAsync.DoAsync(schema, id);
}
}
public async Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
await queryScheduledItems.DoAsync(now, callback);
}
}
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryIdsAsync.DoAsync(appId, ids);
}
}
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryIdsAsync.DoAsync(appId, schemaId, filterNode);
}
}
public Task<MongoContentEntity> FindAsync(Guid id)
{
return Collection.Find(x => x.Id == id).FirstOrDefaultAsync();
}
public Task UpsertVersionedAsync(Guid id, long oldVersion, MongoContentEntity entity)
{
return Collection.UpsertVersionedAsync(id, oldVersion, entity);
}
public Task RemoveAsync(Guid id)
{
return Collection.DeleteOneAsync(x => x.Id == id);
}
}
}

119
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs

@ -0,0 +1,119 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public sealed class MongoContentCollectionPublished : MongoRepositoryBase<MongoContentEntity>
{
private readonly QueryContent queryContentAsync;
private readonly QueryContentsByIds queryContentsById;
private readonly QueryContentsByQuery queryContentsByQuery;
private readonly QueryIdsAsync queryIdsAsync;
public MongoContentCollectionPublished(IMongoDatabase database, IAppProvider appProvider, IContentTextIndex indexer, IJsonSerializer serializer)
: base(database)
{
queryContentAsync = new QueryContent(serializer);
queryContentsById = new QueryContentsByIds(serializer, appProvider);
queryContentsByQuery = new QueryContentsByQuery(serializer, indexer);
queryIdsAsync = new QueryIdsAsync(appProvider);
}
public IMongoCollection<MongoContentEntity> GetInternalCollection()
{
return Collection;
}
protected override string CollectionName()
{
return "State_Contents_Published";
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default)
{
await queryContentAsync.PrepareAsync(collection, ct);
await queryContentsById.PrepareAsync(collection, ct);
await queryContentsByQuery.PrepareAsync(collection, ct);
await queryIdsAsync.PrepareAsync(collection, ct);
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query)
{
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
{
return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.Published);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids)
{
Guard.NotNull(app);
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
{
var result = await queryContentsById.DoAsync(app.Id, schema, ids);
return ResultList.Create(result.Count, result.Select(x => x.Content));
}
}
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<Guid> ids)
{
Guard.NotNull(app);
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
{
var result = await queryContentsById.DoAsync(app.Id, null, ids);
return result;
}
}
public async Task<IContentEntity?> FindContentAsync(ISchemaEntity schema, Guid id)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryContentAsync.DoAsync(schema, id);
}
}
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryIdsAsync.DoAsync(appId, ids);
}
}
public Task UpsertVersionedAsync(Guid id, long oldVersion, MongoContentEntity entity)
{
return Collection.UpsertVersionedAsync(id, oldVersion, entity);
}
public Task RemoveAsync(Guid id)
{
return Collection.DeleteOneAsync(x => x.Id == id);
}
}
}

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

@ -14,7 +14,6 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
@ -25,8 +24,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonIgnoreExtraElements]
public sealed class MongoContentEntity : IContentEntity, IVersionedEntity<Guid>
{
private NamedContentData? data;
private NamedContentData dataDraft;
private NamedContentData data;
[BsonId]
[BsonElement("_id")]
@ -53,19 +51,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public Status Status { get; set; }
[BsonIgnoreIfNull]
[BsonElement("do")]
[BsonJson]
public IdContentData DataByIds { get; set; }
[BsonIgnoreIfNull]
[BsonElement("dd")]
[BsonJson]
public IdContentData DataDraftByIds { get; set; }
[BsonElement("ns")]
public Status? NewStatus { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sj")]
[BsonElement("do")]
[BsonJson]
public ScheduleJob? ScheduleJob { get; set; }
public IdContentData DataByIds { get; set; }
[BsonRequired]
[BsonElement("ai")]
@ -96,8 +88,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public bool IsDeleted { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("pd")]
public bool IsPending { get; set; }
[BsonElement("sj")]
public ScheduleJob? ScheduleJob { get; set; }
[BsonRequired]
[BsonElement("cb")]
@ -108,44 +100,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public RefToken LastModifiedBy { get; set; }
[BsonIgnore]
public NamedContentData? Data
public NamedContentData Data
{
get { return data; }
}
[BsonIgnore]
public NamedContentData DataDraft
public void LoadData(NamedContentData data, Schema schema, IJsonSerializer serializer)
{
get { return dataDraft; }
}
ReferencedIds = data.GetReferencedIds(schema);
public void LoadData(ContentState value, Schema schema, IJsonSerializer serializer)
{
ReferencedIds = value.Data.GetReferencedIds(schema);
DataByIds = value.Data.ToMongoModel(schema, serializer);
if (!ReferenceEquals(value.Data, value.DataDraft))
{
DataDraftByIds = value.DataDraft.ToMongoModel(schema, serializer);
}
else
{
DataDraftByIds = DataByIds;
}
DataByIds = data.ToMongoModel(schema, serializer);
}
public void ParseData(Schema schema, IJsonSerializer serializer)
{
if (DataByIds != null)
{
data = DataByIds.FromMongoModel(schema, serializer);
}
if (DataDraftByIds != null)
{
dataDraft = DataDraftByIds.FromMongoModel(schema, serializer);
}
data = DataByIds.FromMongoModel(schema, serializer);
}
}
}

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

@ -7,43 +7,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public partial class MongoContentRepository : MongoRepositoryBase<MongoContentEntity>, IContentRepository
public partial class MongoContentRepository : IContentRepository, IInitializable
{
private readonly IAppProvider appProvider;
private readonly IJsonSerializer serializer;
private readonly QueryContent queryContentAsync;
private readonly QueryContentsByIds queryContentsById;
private readonly QueryContentsByQuery queryContentsByQuery;
private readonly QueryIdsAsync queryIdsAsync;
private readonly QueryScheduledContents queryScheduledItems;
private readonly MongoContentCollectionAll collectionAll;
private readonly MongoContentCollectionPublished collectionPublished;
static MongoContentRepository()
{
StatusSerializer.Register();
}
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, ITextIndexer indexer, IJsonSerializer serializer)
: base(database)
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, IContentTextIndex indexer, IJsonSerializer serializer)
{
Guard.NotNull(appProvider);
Guard.NotNull(serializer);
@ -52,94 +43,90 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
this.serializer = serializer;
queryContentAsync = new QueryContent(serializer);
queryContentsById = new QueryContentsByIds(serializer, appProvider);
queryContentsByQuery = new QueryContentsByQuery(serializer, indexer);
queryIdsAsync = new QueryIdsAsync(appProvider);
queryScheduledItems = new QueryScheduledContents();
collectionAll = new MongoContentCollectionAll(database, appProvider, indexer, serializer);
collectionPublished = new MongoContentCollectionPublished(database, appProvider, indexer, serializer);
}
public IMongoCollection<MongoContentEntity> GetInternalCollection()
public async Task InitializeAsync(CancellationToken ct = default)
{
return Collection;
await collectionAll.InitializeAsync(ct);
await collectionPublished.InitializeAsync(ct);
}
protected override string CollectionName()
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope)
{
return "State_Contents";
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default)
{
await queryContentAsync.PrepareAsync(collection, ct);
await queryContentsById.PrepareAsync(collection, ct);
await queryContentsByQuery.PrepareAsync(collection, ct);
await queryIdsAsync.PrepareAsync(collection, ct);
await queryScheduledItems.PrepareAsync(collection, ct);
if (scope == SearchScope.All)
{
return collectionAll.QueryAsync(app, schema, query);
}
else
{
return collectionPublished.QueryAsync(app, schema, query);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[]? status, bool inDraft, ClrQuery query, bool includeDraft = true)
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids, SearchScope scope)
{
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
if (scope == SearchScope.All)
{
return await queryContentsByQuery.DoAsync(app, schema, query, status, inDraft, includeDraft);
return collectionAll.QueryAsync(app, schema, ids);
}
else
{
return collectionPublished.QueryAsync(app, schema, ids);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[]? status, HashSet<Guid> ids, bool includeDraft = true)
public Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<Guid> ids, SearchScope scope)
{
Guard.NotNull(app);
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
if (scope == SearchScope.All)
{
var result = await queryContentsById.DoAsync(app.Id, schema, ids, status, includeDraft);
return ResultList.Create(result.Count, result.Select(x => x.Content));
return collectionAll.QueryAsync(app, ids);
}
else
{
return collectionPublished.QueryAsync(app, ids);
}
}
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, Status[]? status, HashSet<Guid> ids, bool includeDraft = true)
public Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, SearchScope scope)
{
Guard.NotNull(app);
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
if (scope == SearchScope.All)
{
var result = await queryContentsById.DoAsync(app.Id, null, ids, status, includeDraft);
return result;
return collectionAll.FindContentAsync(schema, id);
}
else
{
return collectionPublished.FindContentAsync(schema, id);
}
}
public async Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[]? status, Guid id, bool includeDraft = true)
public Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids, SearchScope scope)
{
using (Profiler.TraceMethod<MongoContentRepository>())
if (scope == SearchScope.All)
{
return collectionAll.QueryIdsAsync(appId, ids);
}
else
{
return await queryContentAsync.DoAsync(schema, id, status, includeDraft);
return collectionPublished.QueryIdsAsync(appId, ids);
}
}
public async Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
await queryScheduledItems.DoAsync(now, callback);
}
return collectionAll.QueryScheduledWithoutDataAsync(now, callback);
}
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids)
public Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryIdsAsync.DoAsync(appId, ids);
}
return collectionAll.QueryIdsAsync(appId, schemaId, filterNode);
}
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode)
public IEnumerable<IMongoCollection<MongoContentEntity>> GetInternalCollections()
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryIdsAsync.DoAsync(appId, schemaId, filterNode);
}
yield return collectionAll.GetInternalCollection();
yield return collectionPublished.GetInternalCollection();
}
}
}

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

@ -8,12 +8,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
@ -26,11 +25,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
throw new NotSupportedException();
}
async Task ISnapshotStore<ContentState, Guid>.ClearAsync()
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
await collectionAll.ClearAsync();
await collectionPublished.ClearAsync();
}
}
async Task ISnapshotStore<ContentState, Guid>.RemoveAsync(Guid key)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
await Collection.DeleteOneAsync(x => x.Id == key);
await collectionAll.RemoveAsync(key);
await collectionPublished.RemoveAsync(key);
}
}
@ -38,9 +47,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
var contentEntity =
await Collection.Find(x => x.Id == key)
.FirstOrDefaultAsync();
var contentEntity = await collectionAll.FindAsync(key);
if (contentEntity != null)
{
@ -66,23 +73,63 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id);
var content = SimpleMapper.Map(value, new MongoContentEntity
{
IsDeleted = value.IsDeleted,
IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id,
Version = newVersion
});
await Task.WhenAll(
UpsertDraftContentAsync(value, oldVersion, newVersion, schema),
UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema));
}
}
content.LoadData(value, schema.SchemaDef, serializer);
private async Task UpsertOrDeletePublishedAsync(ContentState value, long oldVersion, long newVersion, ISchemaEntity schema)
{
if (value.Status == Status.Published)
{
await UpsertPublishedContentAsync(value, oldVersion, newVersion, schema);
}
else
{
await DeletePublishedContentAsync(value.Id);
}
}
if (value.ScheduleJob != null)
{
content.ScheduledAt = value.ScheduleJob.DueTime;
}
private Task DeletePublishedContentAsync(Guid key)
{
return collectionPublished.RemoveAsync(key);
}
await Collection.UpsertVersionedAsync(content.Id, oldVersion, content);
}
private async Task UpsertDraftContentAsync(ContentState value, long oldVersion, long newVersion, ISchemaEntity schema)
{
var content = SimpleMapper.Map(value, new MongoContentEntity
{
IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id,
Version = newVersion
});
content.ScheduledAt = value.ScheduleJob?.DueTime;
content.ScheduleJob = value.ScheduleJob;
content.NewStatus = value.NewStatus;
content.LoadData(value.Data, schema.SchemaDef, serializer);
await collectionAll.UpsertVersionedAsync(content.Id, oldVersion, content);
}
private async Task UpsertPublishedContentAsync(ContentState value, long oldVersion, long newVersion, ISchemaEntity schema)
{
var content = SimpleMapper.Map(value, new MongoContentEntity
{
IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id,
Version = newVersion
});
content.ScheduledAt = null;
content.ScheduleJob = null;
content.NewStatus = null;
content.LoadData(value.CurrentVersion.Data, schema.SchemaDef, serializer);
await collectionPublished.UpsertVersionedAsync(content.Id, oldVersion, content);
}
private async Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid schemaId)

19
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs

@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return x.Name;
}
public static Func<PropertyPath, PropertyPath> Path(Schema schema, bool inDraft)
public static Func<PropertyPath, PropertyPath> Path(Schema schema)
{
return propertyNames =>
{
@ -74,14 +74,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{
if (result[0].Equals("Data", StringComparison.CurrentCultureIgnoreCase))
{
if (inDraft)
{
result[0] = "dd";
}
else
{
result[0] = "do";
}
result[0] = "do";
}
else
{
@ -93,9 +86,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
};
}
public static ClrQuery AdjustToModel(this ClrQuery query, Schema schema, bool useDraft)
public static ClrQuery AdjustToModel(this ClrQuery query, Schema schema)
{
var pathConverter = Path(schema, useDraft);
var pathConverter = Path(schema);
if (query.Filter != null)
{
@ -107,9 +100,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return query;
}
public static FilterNode<ClrValue>? AdjustToModel(this FilterNode<ClrValue> filterNode, Schema schema, bool useDraft)
public static FilterNode<ClrValue>? AdjustToModel(this FilterNode<ClrValue> filterNode, Schema schema)
{
var pathConverter = Path(schema, useDraft);
var pathConverter = Path(schema);
return filterNode.Accept(new AdaptionVisitor(pathConverter));
}

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

@ -5,13 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{
@ -34,15 +31,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
FieldConverters.ForNestedName2Id(
ValueConverters.EncodeJson(serializer)));
}
public static bool HasStatus(this MongoContentEntity content, Status[]? status)
{
return status == null || status.Contains(content.Status);
}
public static IFindFluent<MongoContentEntity, MongoContentEntity> WithoutDraft(this IFindFluent<MongoContentEntity, MongoContentEntity> cursor, bool includeDraft)
{
return !includeDraft ? cursor.Not(x => x.DataDraftByIds, x => x.IsDeleted) : cursor;
}
}
}

7
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContent.cs

@ -8,7 +8,6 @@
using System;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
@ -25,17 +24,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
this.serializer = serializer;
}
public async Task<IContentEntity?> DoAsync(ISchemaEntity schema, Guid id, Status[]? status, bool includeDraft)
public async Task<IContentEntity?> DoAsync(ISchemaEntity schema, Guid id)
{
Guard.NotNull(schema);
var find = Collection.Find(x => x.Id == id).WithoutDraft(includeDraft);
var find = Collection.Find(x => x.Id == id);
var contentEntity = await find.FirstOrDefaultAsync();
if (contentEntity != null)
{
if (contentEntity.IndexedSchemaId != schema.Id || !contentEntity.HasStatus(status))
if (contentEntity.IndexedSchemaId != schema.Id)
{
return null;
}

14
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
@ -30,11 +29,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
this.appProvider = appProvider;
}
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> DoAsync(Guid appId, ISchemaEntity? schema, HashSet<Guid> ids, Status[]? status, bool includeDraft)
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> DoAsync(Guid appId, ISchemaEntity? schema, HashSet<Guid> ids)
{
Guard.NotNull(ids);
var find = Collection.Find(CreateFilter(appId, ids, status)).WithoutDraft(includeDraft);
var find = Collection.Find(CreateFilter(appId, ids));
var contentItems = await find.ToListAsync();
var contentSchemas = await GetSchemasAsync(appId, schema, contentItems);
@ -43,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
foreach (var contentEntity in contentItems)
{
if (contentEntity.HasStatus(status) && contentSchemas.TryGetValue(contentEntity.IndexedSchemaId, out var contentSchema))
if (contentSchemas.TryGetValue(contentEntity.IndexedSchemaId, out var contentSchema))
{
contentEntity.ParseData(contentSchema.SchemaDef, serializer);
@ -81,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return schemas;
}
private static FilterDefinition<MongoContentEntity> CreateFilter(Guid appId, ICollection<Guid> ids, Status[]? status)
private static FilterDefinition<MongoContentEntity> CreateFilter(Guid appId, ICollection<Guid> ids)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -89,11 +88,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
Filter.Ne(x => x.IsDeleted, true)
};
if (status != null)
{
filters.Add(Filter.In(x => x.Status, status));
}
if (ids != null && ids.Count > 0)
{
if (ids.Count > 1)

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

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text;
@ -25,9 +24,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
internal sealed class QueryContentsByQuery : OperationBase
{
private readonly IJsonSerializer serializer;
private readonly ITextIndexer indexer;
private readonly IContentTextIndex indexer;
public QueryContentsByQuery(IJsonSerializer serializer, ITextIndexer indexer)
public QueryContentsByQuery(IJsonSerializer serializer, IContentTextIndex indexer)
{
this.serializer = serializer;
@ -40,13 +39,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
new CreateIndexModel<MongoContentEntity>(Index
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Status)
.Ascending(x => x.ReferencedIds)
.Descending(x => x.LastModified));
return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct);
}
public async Task<IResultList<IContentEntity>> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, Status[]? status, bool inDraft, bool includeDraft = true)
public async Task<IResultList<IContentEntity>> DoAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope)
{
Guard.NotNull(app);
Guard.NotNull(schema);
@ -54,13 +53,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
try
{
query = query.AdjustToModel(schema.SchemaDef, inDraft);
query = query.AdjustToModel(schema.SchemaDef);
List<Guid>? fullTextIds = null;
if (!string.IsNullOrWhiteSpace(query.FullText))
{
fullTextIds = await indexer.SearchAsync(query.FullText, app, schema.Id, inDraft ? Scope.Draft : Scope.Published);
var searchFilter = SearchFilter.ShouldHaveSchemas(schema.Id);
fullTextIds = await indexer.SearchAsync(query.FullText, app, searchFilter, scope);
if (fullTextIds?.Count == 0)
{
@ -68,12 +69,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
}
}
var filter = CreateFilter(schema.Id, fullTextIds, status, query);
var filter = CreateFilter(schema.Id, fullTextIds, query);
var contentCount = Collection.Find(filter).CountDocumentsAsync();
var contentItems =
Collection.Find(filter)
.WithoutDraft(includeDraft)
.QueryLimit(query)
.QuerySkip(query)
.QuerySort(query)
@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
}
}
private static FilterDefinition<MongoContentEntity> CreateFilter(Guid schemaId, ICollection<Guid>? ids, Status[]? status, ClrQuery? query)
private static FilterDefinition<MongoContentEntity> CreateFilter(Guid schemaId, ICollection<Guid>? ids, ClrQuery? query)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -109,14 +109,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
Filter.Ne(x => x.IsDeleted, true)
};
if (status != null)
{
filters.Add(Filter.In(x => x.Status, status));
}
if (ids != null && ids.Count > 0)
{
filters.Add(Filter.In(x => x.Id, ids));
filters.Add(
Filter.Or(
Filter.AnyIn(x => x.ReferencedIds, ids),
Filter.In(x => x.Id, ids)));
}
if (query?.Filter != null)

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs

@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return EmptyIds;
}
var filter = BuildFilter(filterNode.AdjustToModel(schema.SchemaDef, true), schemaId);
var filter = BuildFilter(filterNode.AdjustToModel(schema.SchemaDef), schemaId);
var contentEntities =
await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId)

1
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduledContents.cs

@ -34,7 +34,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true)
.Not(x => x.DataByIds)
.Not(x => x.DataDraftByIds)
.ForEachAsync(c =>
{
callback(c);

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoIndexStorage.cs

@ -10,7 +10,7 @@ using System.IO;
using System.Threading.Tasks;
using Lucene.Net.Index;
using MongoDB.Driver.GridFS;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Contents.Text.Lucene;
using LuceneDirectory = Lucene.Net.Store.Directory;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText

61
backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexerState.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText
{
public sealed class MongoTextIndexerState : MongoRepositoryBase<TextContentState>, ITextIndexerState
{
static MongoTextIndexerState()
{
BsonClassMap.RegisterClassMap<TextContentState>(cm =>
{
cm.MapIdField(x => x.ContentId);
cm.MapProperty(x => x.DocIdCurrent)
.SetElementName("c");
cm.MapProperty(x => x.DocIdNew)
.SetElementName("n").SetIgnoreIfNull(true);
cm.MapProperty(x => x.DocIdForPublished)
.SetElementName("p").SetIgnoreIfNull(true);
});
}
public MongoTextIndexerState(IMongoDatabase database, bool setup = false)
: base(database, setup)
{
}
protected override string CollectionName()
{
return "TextIndexerState";
}
public Task<TextContentState?> GetAsync(Guid contentId)
{
return Collection.Find(x => x.ContentId == contentId).FirstOrDefaultAsync()!;
}
public Task RemoveAsync(Guid contentId)
{
return Collection.DeleteOneAsync(x => x.ContentId == contentId);
}
public Task SetAsync(TextContentState state)
{
return Collection.ReplaceOneAsync(x => x.ContentId == state.ContentId, state, UpsertReplace);
}
}
}

12
backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.History.Repositories;
@ -18,6 +19,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
{
public class MongoHistoryEventRepository : MongoRepositoryBase<HistoryEvent>, IHistoryEventRepository
{
static MongoHistoryEventRepository()
{
BsonClassMap.RegisterClassMap<HistoryEvent>(cm =>
{
cm.AutoMap();
cm.MapProperty(x => x.EventType)
.SetElementName("Message");
});
}
public MongoHistoryEventRepository(IMongoDatabase database)
: base(database)
{

10
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleStatisticsCollection.cs

@ -25,14 +25,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
{
var guidSerializer = new GuidSerializer().WithRepresentation(BsonType.String);
BsonClassMap.RegisterClassMap<RuleStatistics>(map =>
BsonClassMap.RegisterClassMap<RuleStatistics>(cm =>
{
map.AutoMap();
cm.AutoMap();
map.MapProperty(x => x.AppId).SetSerializer(guidSerializer);
map.MapProperty(x => x.RuleId).SetSerializer(guidSerializer);
cm.MapProperty(x => x.AppId).SetSerializer(guidSerializer);
cm.MapProperty(x => x.RuleId).SetSerializer(guidSerializer);
map.SetIgnoreExtraElements(true);
cm.SetIgnoreExtraElements(true);
});
}

92
backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs

@ -0,0 +1,92 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AppSettingsSearchSource : ISearchSource
{
private const int MaxItems = 3;
private readonly IUrlGenerator urlGenerator;
public AppSettingsSearchSource(IUrlGenerator urlGenerator)
{
Guard.NotNull(urlGenerator);
this.urlGenerator = urlGenerator;
}
public Task<SearchResults> SearchAsync(string query, Context context)
{
var result = new SearchResults();
var appId = context.App.NamedId();
void Search(string term, string permissionId, Func<NamedId<Guid>, string> generate, SearchResultType type)
{
if (result.Count < MaxItems && term.Contains(query, StringComparison.OrdinalIgnoreCase))
{
var permission = Permissions.ForApp(permissionId, appId.Name);
if (context.Permissions.Allows(permission))
{
var url = generate(appId);
result.Add(term, type, url);
}
}
}
Search("Assets", Permissions.AppAssetsRead,
urlGenerator.AssetsUI, SearchResultType.Asset);
Search("Backups", Permissions.AppBackupsRead,
urlGenerator.BackupsUI, SearchResultType.Setting);
Search("Clients", Permissions.AppClientsRead,
urlGenerator.ClientsUI, SearchResultType.Setting);
Search("Contents", Permissions.AppCommon,
urlGenerator.ContentsUI, SearchResultType.Content);
Search("Contributors", Permissions.AppContributorsRead,
urlGenerator.ContributorsUI, SearchResultType.Setting);
Search("Dashboard", Permissions.AppCommon,
urlGenerator.DashboardUI, SearchResultType.Dashboard);
Search("Languages", Permissions.AppCommon,
urlGenerator.LanguagesUI, SearchResultType.Setting);
Search("Patterns", Permissions.AppCommon,
urlGenerator.PatternsUI, SearchResultType.Setting);
Search("Roles", Permissions.AppRolesRead,
urlGenerator.RolesUI, SearchResultType.Setting);
Search("Rules", Permissions.AppRulesRead,
urlGenerator.RulesUI, SearchResultType.Rule);
Search("Schemas", Permissions.AppCommon,
urlGenerator.SchemasUI, SearchResultType.Schema);
Search("Subscription", Permissions.AppPlansRead,
urlGenerator.PlansUI, SearchResultType.Setting);
Search("Workflows", Permissions.AppWorkflowsRead,
urlGenerator.WorkflowsUI, SearchResultType.Setting);
return Task.FromResult(result);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
return UpdateImage(e, ev => null);
case AppPlanChanged e when Is.Change(Plan?.PlanId, e.PlanId):
return UpdatePlan(e, ev => AppPlan.Build(ev.Actor, ev.PlanId));
return UpdatePlan(e, ev => new AppPlan(ev.Actor, ev.PlanId));
case AppPlanReset e when Plan != null:
return UpdatePlan(e, ev => null);

60
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs

@ -0,0 +1,60 @@
// ==========================================================================
// 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;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetsSearchSource : ISearchSource
{
private readonly IAssetQueryService assetQuery;
private readonly IUrlGenerator urlGenerator;
public AssetsSearchSource(IAssetQueryService assetQuery, IUrlGenerator urlGenerator)
{
Guard.NotNull(assetQuery);
Guard.NotNull(urlGenerator);
this.assetQuery = assetQuery;
this.urlGenerator = urlGenerator;
}
public async Task<SearchResults> SearchAsync(string query, Context context)
{
var result = new SearchResults();
var permission = Permissions.ForApp(Permissions.AppAssetsRead, context.App.Name);
if (context.Permissions.Allows(permission))
{
var filter = ClrFilter.Contains("fileName", query);
var clrQuery = new ClrQuery { Filter = filter, Take = 5 };
var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithQuery(clrQuery));
if (assets.Count > 0)
{
var url = urlGenerator.AssetsUI(context.App.NamedId(), query);
foreach (var asset in assets)
{
result.Add(asset.FileName, SearchResultType.Asset, url);
}
}
}
return result;
}
}
}

81
backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs

@ -55,12 +55,12 @@ namespace Squidex.Domain.Apps.Entities.Assets
public Task EnhanceAsync(UploadAssetCommand command, HashSet<string>? tags)
{
Enhance(command, tags);
Enhance(command);
return TaskHelper.Done;
}
private void Enhance(UploadAssetCommand command, HashSet<string>? tags)
private void Enhance(UploadAssetCommand command)
{
try
{
@ -93,26 +93,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
command.Metadata.SetPixelWidth(pw);
command.Metadata.SetPixelHeight(ph);
if (tags != null)
{
tags.Add("image");
var wh = pw + ph;
if (wh > 2000)
{
tags.Add("image/large");
}
else if (wh > 1000)
{
tags.Add("image/medium");
}
else
{
tags.Add("image/small");
}
}
}
void TryAddString(string name, string? value)
@ -149,8 +129,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
if (file.Tag is ImageTag imageTag)
{
TryAddDouble("locationLatitude", imageTag.Latitude);
TryAddDouble("locationLongitude", imageTag.Longitude);
TryAddDouble("latitude", imageTag.Latitude);
TryAddDouble("longitude", imageTag.Longitude);
TryAddString("created", imageTag.DateTime?.ToIso8601());
}
@ -178,44 +158,25 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
var metadata = asset.Metadata;
switch (asset.Type)
if (asset.Type == AssetType.Video)
{
case AssetType.Image:
{
if (metadata.TryGetNumber("pixelWidth", out var w) &&
metadata.TryGetNumber("pixelHeight", out var h))
{
yield return $"{w}x{h}px";
}
break;
}
case AssetType.Video:
{
if (metadata.TryGetNumber("videoWidth", out var w) &&
metadata.TryGetNumber("videoHeight", out var h))
{
yield return $"{w}x{h}pt";
}
if (metadata.TryGetString("duration", out var duration))
{
yield return duration;
}
break;
}
case AssetType.Audio:
{
if (metadata.TryGetString("duration", out var duration))
{
yield return duration;
}
if (metadata.TryGetNumber("videoWidth", out var w) &&
metadata.TryGetNumber("videoHeight", out var h))
{
yield return $"{w}x{h}pt";
}
break;
}
if (metadata.TryGetString("duration", out var duration))
{
yield return duration;
}
}
else if (asset.Type == AssetType.Audio)
{
if (metadata.TryGetString("duration", out var duration))
{
yield return duration;
}
}
}
}

45
backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs

@ -39,34 +39,41 @@ namespace Squidex.Domain.Apps.Entities.Assets
command.Metadata.SetPixelWidth(imageInfo.PixelWidth);
command.Metadata.SetPixelHeight(imageInfo.PixelHeight);
}
}
}
if (tags != null)
{
tags.Add("image");
if (command.Type == AssetType.Image && tags != null)
{
tags.Add("image");
var wh = imageInfo.PixelWidth + imageInfo.PixelHeight;
var wh = command.Metadata.GetPixelWidth() + command.Metadata.GetPixelWidth();
if (wh > 2000)
{
tags.Add("image/large");
}
else if (wh > 1000)
{
tags.Add("image/medium");
}
else
{
tags.Add("image/small");
}
}
}
if (wh > 2000)
{
tags.Add("image/large");
}
else if (wh > 1000)
{
tags.Add("image/medium");
}
else
{
tags.Add("image/small");
}
}
}
public IEnumerable<string> Format(IAssetEntity asset)
{
yield break;
if (asset.Type == AssetType.Image)
{
if (asset.Metadata.TryGetNumber("pixelWidth", out var w) &&
asset.Metadata.TryGetNumber("pixelHeight", out var h))
{
yield return $"{w}x{h}px";
}
}
}
}
}

21
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs

@ -47,15 +47,26 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
using (Profiler.TraceMethod<AssetQueryParser>())
{
var result = new ClrQuery();
ClrQuery result;
if (!string.IsNullOrWhiteSpace(q?.JsonQuery))
if (q.Query != null)
{
result = ParseJson(q.JsonQuery);
result = q.Query;
}
else if (!string.IsNullOrWhiteSpace(q?.ODataQuery))
else
{
result = ParseOData(q.ODataQuery);
if (!string.IsNullOrWhiteSpace(q?.JsonQuery))
{
result = ParseJson(q.JsonQuery);
}
else if (!string.IsNullOrWhiteSpace(q?.ODataQuery))
{
result = ParseOData(q.ODataQuery);
}
else
{
result = new ClrQuery();
}
}
if (result.Filter != null)

7
backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetFolderState.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Runtime.Serialization;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
@ -19,16 +18,10 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
{
public sealed class AssetFolderState : DomainObjectState<AssetFolderState>, IAssetFolderEntity
{
[DataMember]
public NamedId<Guid> AppId { get; set; }
[DataMember]
public string FolderName { get; set; }
[DataMember]
public bool IsDeleted { get; set; }
[DataMember]
public Guid ParentId { get; set; }
public override bool ApplyEvent(IEvent @event)

17
backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -21,48 +20,32 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
{
public class AssetState : DomainObjectState<AssetState>, IAssetEntity
{
[DataMember]
public NamedId<Guid> AppId { get; set; }
[DataMember]
public Guid ParentId { get; set; }
[DataMember]
public string FileName { get; set; }
[DataMember]
public string FileHash { get; set; }
[DataMember]
public string MimeType { get; set; }
[DataMember]
public string Slug { get; set; }
[DataMember]
public long FileVersion { get; set; }
[DataMember]
public long FileSize { get; set; }
[DataMember]
public long TotalSize { get; set; }
[DataMember]
public bool IsProtected { get; set; }
[DataMember]
public HashSet<string> Tags { get; set; }
[DataMember]
public AssetMetadata Metadata { get; set; }
[DataMember]
public AssetType Type { get; set; }
[DataMember]
public bool IsDeleted { get; set; }
public Guid AssetId
{
get { return Id; }

7
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs

@ -6,29 +6,22 @@
// ==========================================================================
using System;
using System.Runtime.Serialization;
using NodaTime;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public sealed class BackupJob : IBackupJob
{
[DataMember]
public Guid Id { get; set; }
[DataMember]
public Instant Started { get; set; }
[DataMember]
public Instant? Stopped { get; set; }
[DataMember]
public int HandledEvents { get; set; }
[DataMember]
public int HandledAssets { get; set; }
[DataMember]
public JobStatus Status { get; set; }
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs

@ -6,13 +6,11 @@
// ==========================================================================
using System.Collections.Generic;
using System.Runtime.Serialization;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public sealed class BackupState
{
[DataMember]
public List<BackupJob> Jobs { get; } = new List<BackupJob>();
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs

@ -7,43 +7,31 @@
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using NodaTime;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
[DataContract]
public sealed class RestoreJob : IRestoreJob
{
[DataMember]
public string AppName { get; set; }
[DataMember]
public Guid Id { get; set; }
[DataMember]
public NamedId<Guid> AppId { get; set; }
[DataMember]
public RefToken Actor { get; set; }
[DataMember]
public Uri Url { get; set; }
[DataMember]
public string? NewAppName { get; set; }
[DataMember]
public Instant Started { get; set; }
[DataMember]
public Instant? Stopped { get; set; }
[DataMember]
public List<string> Log { get; set; } = new List<string>();
[DataMember]
public JobStatus Status { get; set; }
public string? NewAppName { get; set; }
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs

@ -5,13 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.Serialization;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public class RestoreState2
{
[DataMember]
public RestoreJob Job { get; set; }
}
}

1
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs

@ -9,6 +9,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public abstract class ContentUpdateCommand : ContentDataCommand
{
public bool AsDraft { get; set; }
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContentDraft.cs

@ -1,13 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class DiscardChanges : ContentCommand
public sealed class CreateContentDraft : ContentCommand
{
}
}

13
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContentDraft.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class DeleteContentDraft : ContentCommand
{
}
}

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

@ -45,8 +45,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
SimpleMapper.Map(content, result);
result.Data = content.Data ?? content.DataDraft;
switch (@event.Payload)
{
case ContentCreated _:
@ -55,13 +53,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
case ContentDeleted _:
result.Type = EnrichedContentEventType.Deleted;
break;
case ContentChangesPublished _:
result.Type = EnrichedContentEventType.Updated;
break;
case ContentStatusChanged contentStatusChanged:
case ContentStatusChanged statusChanged:
{
switch (contentStatusChanged.Change)
switch (statusChanged.Change)
{
case StatusChange.Published:
result.Type = EnrichedContentEventType.Published;
@ -86,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
content.Id,
content.Version - 1);
result.DataOld = previousContent.Data ?? previousContent.DataDraft;
result.DataOld = previousContent.Data;
break;
}
}

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

@ -7,6 +7,7 @@
using System;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
@ -67,10 +68,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, c, () => "Failed to create content.");
var status = (await contentWorkflow.GetInitialStatusAsync(ctx.Schema)).Status;
await GuardContent.CanCreate(ctx.Schema, contentWorkflow, c);
var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema);
if (!c.DoNotScript)
{
c.Data = await ctx.ExecuteScriptAndTransformAsync(s => s.Create,
@ -107,24 +108,46 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Snapshot;
});
case CreateContentDraft createContentDraft:
return UpdateReturnAsync(createContentDraft, async c =>
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, c, () => "Failed to create draft.");
GuardContent.CanCreateDraft(c, ctx.Schema, Snapshot);
var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema);
CreateDraft(c, status);
return Snapshot;
});
case DeleteContentDraft deleteContentDraft:
return UpdateReturnAsync(deleteContentDraft, async c =>
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, c, () => "Failed to delete draft.");
GuardContent.CanDeleteDraft(c, ctx.Schema, Snapshot);
DeleteDraft(c);
return Snapshot;
});
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async c =>
{
var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
await GuardContent.CanUpdate(Snapshot, contentWorkflow, c, isProposal);
await GuardContent.CanUpdate(Snapshot, contentWorkflow, c);
return await UpdateAsync(c, x => c.Data, false, isProposal);
return await UpdateAsync(c, x => c.Data, false);
});
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async c =>
{
var isProposal = IsProposal(c);
await GuardContent.CanPatch(Snapshot, contentWorkflow, c, isProposal);
await GuardContent.CanPatch(Snapshot, contentWorkflow, c);
return await UpdateAsync(c, c.Data.MergeInto, true, isProposal);
return await UpdateAsync(c, c.Data.MergeInto, true);
});
case ChangeContentStatus changeContentStatus:
@ -132,44 +155,35 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
try
{
var isChangeConfirm = IsConfirm(c);
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, c, () => "Failed to change content.");
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c, isChangeConfirm);
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c);
if (c.DueTime.HasValue)
{
ScheduleStatus(c);
ScheduleStatus(c, c.DueTime.Value);
}
else
{
if (isChangeConfirm)
{
ConfirmChanges(c);
}
else
{
var change = GetChange(c);
await ctx.ExecuteScriptAsync(s => s.Change,
new ScriptContext
{
Operation = change.ToString(),
Data = Snapshot.Data,
Status = c.Status,
StatusOld = Snapshot.Status
});
ChangeStatus(c, change);
}
var change = GetChange(c);
await ctx.ExecuteScriptAsync(s => s.Change,
new ScriptContext
{
Operation = change.ToString(),
Data = Snapshot.Data,
Status = c.Status,
StatusOld = Snapshot.EditingStatus
});
ChangeStatus(c, change);
}
}
catch (Exception)
{
if (c.JobId.HasValue && Snapshot?.ScheduleJob?.Id == c.JobId)
if (Snapshot.ScheduleJob?.Id == c.JobId)
{
CancelScheduling(c);
CancelChangeStatus(c);
}
else
{
@ -180,16 +194,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Snapshot;
});
case DiscardChanges discardChanges:
return UpdateReturn(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
return Snapshot;
});
case DeleteContent deleteContent:
return UpdateAsync(deleteContent, async c =>
{
@ -202,7 +206,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
Operation = "Delete",
Data = Snapshot.Data,
Status = Snapshot.Status,
Status = Snapshot.EditingStatus,
StatusOld = default
});
@ -214,12 +218,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<object> UpdateAsync(ContentUpdateCommand command, Func<NamedContentData, NamedContentData> newDataFunc, bool partial, bool isProposal)
private async Task<object> UpdateAsync(ContentUpdateCommand command, Func<NamedContentData, NamedContentData> newDataFunc, bool partial)
{
var currentData =
isProposal ?
Snapshot.DataDraft :
Snapshot.Data;
var currentData = Snapshot.Data;
var newData = newDataFunc(currentData!);
@ -242,18 +243,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
Operation = "Create",
Data = newData,
DataOld = currentData,
Status = Snapshot.Status,
Status = Snapshot.EditingStatus,
StatusOld = default
});
if (isProposal)
{
ProposeUpdate(command, newData);
}
else
{
Update(command, newData);
}
Update(command, newData);
}
return Snapshot;
@ -269,19 +263,19 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public void ConfirmChanges(ChangeContentStatus command)
public void CreateDraft(CreateContentDraft command, Status status)
{
RaiseEvent(SimpleMapper.Map(command, new ContentChangesPublished()));
RaiseEvent(SimpleMapper.Map(command, new ContentDraftCreated { Status = status }));
}
public void DiscardChanges(DiscardChanges command)
public void Delete(DeleteContent command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded()));
RaiseEvent(SimpleMapper.Map(command, new ContentDeleted()));
}
public void Delete(DeleteContent command)
public void DeleteDraft(DeleteContentDraft command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentDeleted()));
RaiseEvent(SimpleMapper.Map(command, new ContentDraftDeleted()));
}
public void Update(ContentCommand command, NamedContentData data)
@ -289,24 +283,19 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(SimpleMapper.Map(command, new ContentUpdated { Data = data }));
}
public void ProposeUpdate(ContentCommand command, NamedContentData data)
public void ChangeStatus(ChangeContentStatus command, StatusChange change)
{
RaiseEvent(SimpleMapper.Map(command, new ContentUpdateProposed { Data = data }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = change }));
}
public void CancelScheduling(ChangeContentStatus command)
public void CancelChangeStatus(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentSchedulingCancelled()));
}
public void ScheduleStatus(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime!.Value }));
}
public void ChangeStatus(ChangeContentStatus command, StatusChange change)
public void ScheduleStatus(ChangeContentStatus command, Instant dueTime)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = change }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = dueTime }));
}
private void RaiseEvent(SchemaEvent @event)
@ -324,16 +313,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(Envelope.Create(@event));
}
private bool IsConfirm(ChangeContentStatus command)
{
return Snapshot.IsPending && Snapshot.Status == Status.Published && command.Status == Status.Published;
}
private bool IsProposal(PatchContent command)
{
return Snapshot.Status == Status.Published && command.AsDraft;
}
private StatusChange GetChange(ChangeContentStatus command)
{
var change = StatusChange.Change;
@ -342,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
change = StatusChange.Published;
}
else if (Snapshot.Status == Status.Published)
else if (Snapshot.EditingStatus == Status.Published)
{
change = StatusChange.Unpublished;
}

24
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -13,7 +13,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentEntity : IEnrichedContentEntity
public sealed class ContentEntity : IEnrichedContentEntity, IContentEntity
{
public Guid Id { get; set; }
@ -31,28 +31,32 @@ namespace Squidex.Domain.Apps.Entities.Contents
public RefToken LastModifiedBy { get; set; }
public ScheduleJob ScheduleJob { get; set; }
public NamedContentData Data { get; set; }
public NamedContentData? Data { get; set; }
public NamedContentData? ReferenceData { get; set; }
public NamedContentData DataDraft { get; set; }
public ScheduleJob? ScheduleJob { get; set; }
public NamedContentData? ReferenceData { get; set; }
public Status? NewStatus { get; set; }
public Status Status { get; set; }
public StatusInfo[]? Nexts { get; set; }
public StatusInfo[]? NextStatuses { get; set; }
public string StatusColor { get; set; }
public bool CanUpdate { get; set; }
public bool IsSingleton { get; set; }
public string SchemaName { get; set; }
public string SchemaDisplayName { get; set; }
public RootField[]? ReferenceFields { get; set; }
public string StatusColor { get; set; }
public bool CanUpdate { get; set; }
public string? NewStatusColor { get; set; }
public bool IsPending { get; set; }
public string? ScheduledStatusColor { get; set; }
public RootField[]? ReferenceFields { get; set; }
}
}

11
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs

@ -28,14 +28,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
AddEventMessage<ContentDeleted>(
"deleted {[Schema]} content.");
AddEventMessage<ContentChangesDiscarded>(
"discarded pending changes of {[Schema]} content.");
AddEventMessage<ContentDraftCreated>(
"created new draft.");
AddEventMessage<ContentChangesPublished>(
"published changes of {[Schema]} content.");
AddEventMessage<ContentUpdateProposed>(
"proposed update for {[Schema]} content.");
AddEventMessage<ContentDraftDeleted>(
"deleted draft.");
AddEventMessage<ContentSchedulingCancelled>(
"failed to schedule status change for {[Schema]} content.");

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

@ -143,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryContentsAsync(HashSet<Guid> ids)
{
return await contentRepository.QueryIdsAsync(appEntity.Id, ids);
return await contentRepository.QueryIdsAsync(appEntity.Id, ids, SearchScope.All);
}
private string GetScript(Func<SchemaScripts, string> script)

163
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs

@ -0,0 +1,163 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
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.Contents.Text;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentsSearchSource : ISearchSource
{
private readonly IAppProvider appProvider;
private readonly IContentQueryService contentQuery;
private readonly IContentTextIndex contentTextIndexer;
private readonly IUrlGenerator urlGenerator;
public ContentsSearchSource(
IAppProvider appProvider,
IContentQueryService contentQuery,
IContentTextIndex contentTextIndexer,
IUrlGenerator urlGenerator)
{
Guard.NotNull(appProvider);
Guard.NotNull(contentQuery);
Guard.NotNull(contentTextIndexer);
Guard.NotNull(urlGenerator);
this.appProvider = appProvider;
this.contentQuery = contentQuery;
this.contentTextIndexer = contentTextIndexer;
this.urlGenerator = urlGenerator;
}
public async Task<SearchResults> SearchAsync(string query, Context context)
{
var result = new SearchResults();
var searchFilter = await CreateSearchFilterAsync(context);
if (searchFilter == null)
{
return result;
}
var ids = await contentTextIndexer.SearchAsync($"{query}~", context.App, searchFilter, context.Scope());
if (ids == null || ids.Count == 0)
{
return result;
}
var appId = context.App.NamedId();
var contents = await contentQuery.QueryAsync(context, ids);
foreach (var content in contents)
{
var url = urlGenerator.ContentUI(appId, content.SchemaId, content.Id);
var name = FormatName(content, context.App.LanguagesConfig.Master);
result.Add(name, SearchResultType.Content, url, content.SchemaDisplayName);
}
return result;
}
private async Task<SearchFilter?> CreateSearchFilterAsync(Context context)
{
var allowedSchemas = new List<Guid>();
var schemas = await appProvider.GetSchemasAsync(context.App.Id);
foreach (var schema in schemas)
{
if (HasPermission(context, schema.SchemaDef.Name))
{
allowedSchemas.Add(schema.Id);
}
}
if (allowedSchemas.Count == 0)
{
return null;
}
return SearchFilter.MustHaveSchemas(allowedSchemas.ToArray());
}
private static bool HasPermission(Context context, string schemaName)
{
var permission = Permissions.ForApp(Permissions.AppContentsRead, context.App.Name, schemaName);
return context.Permissions.Allows(permission);
}
private string FormatName(IEnrichedContentEntity content, string masterLanguage)
{
var sb = new StringBuilder();
IJsonValue? GetValue(NamedContentData? data, RootField field)
{
if (data != null && data.TryGetValue(field.Name, out var fieldValue) && fieldValue != null)
{
var isInvariant = field.Partitioning.Equals(Partitioning.Invariant);
if (isInvariant && fieldValue.TryGetValue("iv", out var value))
{
return value;
}
if (!isInvariant && fieldValue.TryGetValue(masterLanguage, out value))
{
return value;
}
}
return null;
}
if (content.ReferenceFields != null)
{
foreach (var field in content.ReferenceFields)
{
var value = GetValue(content.ReferenceData, field) ?? GetValue(content.Data, field);
var formatted = StringFormatter.Format(value, field);
if (!string.IsNullOrWhiteSpace(formatted))
{
if (sb.Length > 0)
{
sb.Append(", ");
}
sb.Append(formatted);
}
}
}
if (sb.Length == 0)
{
return "Content";
}
return sb.ToString();
}
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs

@ -84,6 +84,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return SetBoolean(context, HeaderNoResolveLanguages, value);
}
public static SearchScope Scope(this Context context)
{
return context.ShouldProvideUnpublished() || context.IsFrontendClient ? SearchScope.All : SearchScope.Published;
}
public static IEnumerable<string> AssetUrls(this Context context)
{
if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value))

22
backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -48,11 +48,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
})
};
public Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
public Task<Status> GetInitialStatusAsync(ISchemaEntity schema)
{
var result = InfoDraft;
return Task.FromResult(result);
return Task.FromResult(Status.Draft);
}
public Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user)
@ -60,30 +58,30 @@ namespace Squidex.Domain.Apps.Entities.Contents
return TaskHelper.True;
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
public Task<bool> CanMoveToAsync(IContentEntity content, Status status, Status next, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next);
var result = Flow.TryGetValue(status, out var step) && step.Transitions.Any(x => x.Status == next);
return Task.FromResult(result);
}
public Task<bool> CanUpdateAsync(IContentEntity content, ClaimsPrincipal user)
public Task<bool> CanUpdateAsync(IContentEntity content, Status status, ClaimsPrincipal user)
{
var result = content.Status != Status.Archived;
var result = status != Status.Archived;
return Task.FromResult(result);
}
public Task<StatusInfo> GetInfoAsync(IContentEntity content)
public Task<StatusInfo> GetInfoAsync(IContentEntity content, Status status)
{
var result = Flow[content.Status].Info;
var result = Flow[status].Info;
return Task.FromResult(result);
}
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
public Task<StatusInfo[]> GetNextAsync(IContentEntity content, Status status, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty<StatusInfo>();
var result = Flow.TryGetValue(status, out var step) ? step.Transitions : Array.Empty<StatusInfo>();
return Task.FromResult(result);
}

30
backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -39,11 +39,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray();
}
public async Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
public async Task<bool> CanMoveToAsync(IContentEntity content, Status status, Status next, ClaimsPrincipal user)
{
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
return workflow.TryGetTransition(content.Status, next, out var transition) && IsTrue(transition, content.DataDraft, user);
return workflow.TryGetTransition(status, next, out var transition) && IsTrue(transition, content.Data, user);
}
public async Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user)
@ -53,48 +53,48 @@ namespace Squidex.Domain.Apps.Entities.Contents
return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && IsTrue(transition, data, user);
}
public async Task<bool> CanUpdateAsync(IContentEntity content, ClaimsPrincipal user)
public async Task<bool> CanUpdateAsync(IContentEntity content, Status status, ClaimsPrincipal user)
{
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
if (workflow.TryGetStep(content.Status, out var step))
if (workflow.TryGetStep(status, out var step))
{
return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.DataDraft, user);
return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.Data, user);
}
return true;
}
public async Task<StatusInfo> GetInfoAsync(IContentEntity content)
public async Task<StatusInfo> GetInfoAsync(IContentEntity content, Status status)
{
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
if (workflow.TryGetStep(content.Status, out var step))
if (workflow.TryGetStep(status, out var step))
{
return new StatusInfo(content.Status, GetColor(step));
return new StatusInfo(status, GetColor(step));
}
return new StatusInfo(content.Status, StatusColors.Draft);
return new StatusInfo(status, StatusColors.Draft);
}
public async Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
public async Task<Status> GetInitialStatusAsync(ISchemaEntity schema)
{
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id);
var (status, step) = workflow.GetInitialStep();
var (status, _) = workflow.GetInitialStep();
return new StatusInfo(status, GetColor(step));
return status;
}
public async Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
public async Task<StatusInfo[]> GetNextAsync(IContentEntity content, Status status, ClaimsPrincipal user)
{
var result = new List<StatusInfo>();
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
foreach (var (to, step, transition) in workflow.GetTransitions(content.Status))
foreach (var (to, step, transition) in workflow.GetTransitions(status))
{
if (IsTrue(transition, content.DataDraft, user))
if (IsTrue(transition, content.Data, user))
{
result.Add(new StatusInfo(to, GetColor(step)));
}

16
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs

@ -127,14 +127,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Resolver = Resolve(x => x.Data),
Description = $"The data of the {schemaName} content."
});
AddField(new FieldType
{
Name = "dataDraft",
ResolvedType = contentDataType,
Resolver = Resolve(x => x.DataDraft),
Description = $"The draft data of the {schemaName} content."
});
}
var contentDataTypeFlat = new ContentDataFlatGraphType(schema, schemaName, schemaType, model);
@ -148,14 +140,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Resolver = ResolveFlat(x => x.Data),
Description = $"The flat data of the {schemaName} content."
});
AddField(new FieldType
{
Name = "flatDataDraft",
ResolvedType = contentDataTypeFlat,
Resolver = ResolveFlat(x => x.DataDraft),
Description = $"The flat draft data of the {schemaName} content."
});
}
}

56
backend/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation;
@ -38,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
}
}
public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command, bool isProposal)
public static async Task CanUpdate(ContentState content, IContentWorkflow contentWorkflow, UpdateContent command)
{
Guard.NotNull(command);
@ -47,13 +48,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
ValidateData(command, e);
});
if (!isProposal)
{
await ValidateCanUpdate(content, contentWorkflow, command.User);
}
await ValidateCanUpdate(content, contentWorkflow, command.User);
}
public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command, bool isProposal)
public static async Task CanPatch(ContentState content, IContentWorkflow contentWorkflow, PatchContent command)
{
Guard.NotNull(command);
@ -62,41 +60,51 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
ValidateData(command, e);
});
if (!isProposal)
await ValidateCanUpdate(content, contentWorkflow, command.User);
}
public static void CanDeleteDraft(DeleteContentDraft command, ISchemaEntity schema, ContentState content)
{
Guard.NotNull(command);
if (schema.SchemaDef.IsSingleton)
{
throw new DomainException("Singleton content cannot be updated.");
}
if (content.NewStatus == null)
{
await ValidateCanUpdate(content, contentWorkflow, command.User);
throw new DomainException("There is nothing to delete.");
}
}
public static void CanDiscardChanges(bool isPending, DiscardChanges command)
public static void CanCreateDraft(CreateContentDraft command, ISchemaEntity schema, ContentState content)
{
Guard.NotNull(command);
if (!isPending)
if (schema.SchemaDef.IsSingleton)
{
throw new DomainException("The content has no pending changes.");
throw new DomainException("Singleton content cannot be updated.");
}
if (content.Status != Status.Published)
{
throw new DomainException("You can only create a new version when the content is published.");
}
}
public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command, bool isChangeConfirm)
public static Task CanChangeStatus(ISchemaEntity schema, ContentState content, IContentWorkflow contentWorkflow, ChangeContentStatus command)
{
Guard.NotNull(command);
if (schema.SchemaDef.IsSingleton && command.Status != Status.Published)
if (schema.SchemaDef.IsSingleton)
{
throw new DomainException("Singleton content cannot be changed.");
throw new DomainException("Singleton content cannot be updated.");
}
return Validate.It(() => "Cannot change status.", async e =>
{
if (isChangeConfirm)
{
if (!content.IsPending)
{
e("Content has no changes to publish.", nameof(command.Status));
}
}
else if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User))
if (!await contentWorkflow.CanMoveToAsync(content, content.EditingStatus, command.Status, command.User))
{
e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status));
}
@ -126,9 +134,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
}
}
private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, ClaimsPrincipal user)
private static async Task ValidateCanUpdate(ContentState content, IContentWorkflow contentWorkflow, ClaimsPrincipal user)
{
if (!await contentWorkflow.CanUpdateAsync(content, user))
if (!await contentWorkflow.CanUpdateAsync(content, content.EditingStatus, user))
{
throw new DomainException($"The workflow does not allow updates at status {content.Status}");
}

10
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs

@ -22,14 +22,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
NamedId<Guid> SchemaId { get; }
Status Status { get; }
ScheduleJob? ScheduleJob { get; }
Status? NewStatus { get; }
NamedContentData? Data { get; }
Status Status { get; }
NamedContentData DataDraft { get; }
NamedContentData Data { get; }
bool IsPending { get; }
ScheduleJob? ScheduleJob { get; }
}
}

10
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -14,17 +14,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentWorkflow
{
Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<Status> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanMoveToAsync(IContentEntity content, Status status, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content, Status status, ClaimsPrincipal user);
Task<StatusInfo> GetInfoAsync(IContentEntity content);
Task<StatusInfo> GetInfoAsync(IContentEntity content, Status status);
Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user);
Task<StatusInfo[]> GetNextAsync(IContentEntity content, Status status, ClaimsPrincipal user);
Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema);
}

8
backend/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs

@ -14,15 +14,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
bool CanUpdate { get; }
bool IsSingleton { get; }
string StatusColor { get; }
string? NewStatusColor { get; }
string? ScheduledStatusColor { get; }
string SchemaName { get; }
string SchemaDisplayName { get; }
RootField[]? ReferenceFields { get; }
StatusInfo[]? Nexts { get; }
StatusInfo[]? NextStatuses { get; }
NamedContentData? ReferenceData { get; }
}

21
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs

@ -49,15 +49,26 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
using (Profiler.TraceMethod<ContentQueryParser>())
{
var result = new ClrQuery();
ClrQuery result;
if (!string.IsNullOrWhiteSpace(q?.JsonQuery))
if (q.Query != null)
{
result = ParseJson(context, schema, q.JsonQuery);
result = q.Query;
}
else if (!string.IsNullOrWhiteSpace(q?.ODataQuery))
else
{
result = ParseOData(context, schema, q.ODataQuery);
if (!string.IsNullOrWhiteSpace(q?.JsonQuery))
{
result = ParseJson(context, schema, q.JsonQuery);
}
else if (!string.IsNullOrWhiteSpace(q?.ODataQuery))
{
result = ParseOData(context, schema, q.ODataQuery);
}
else
{
result = new ClrQuery();
}
}
if (result.Sort.Count == 0)

98
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -9,14 +9,11 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
#pragma warning disable RECS0147
@ -25,13 +22,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
public sealed class ContentQueryService : IContentQueryService
{
private static readonly Status[] StatusPublishedOnly = { Status.Published };
private static readonly IResultList<IEnrichedContentEntity> EmptyContents = ResultList.CreateFrom<IEnrichedContentEntity>(0);
private readonly IAppProvider appProvider;
private readonly IContentEnricher contentEnricher;
private readonly IContentRepository contentRepository;
private readonly IContentLoader contentVersionLoader;
private readonly IScriptEngine scriptEngine;
private readonly ContentQueryParser queryParser;
public ContentQueryService(
@ -39,7 +34,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
IContentEnricher contentEnricher,
IContentRepository contentRepository,
IContentLoader contentVersionLoader,
IScriptEngine scriptEngine,
ContentQueryParser queryParser)
{
Guard.NotNull(appProvider);
@ -47,14 +41,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
Guard.NotNull(contentRepository);
Guard.NotNull(contentVersionLoader);
Guard.NotNull(queryParser);
Guard.NotNull(scriptEngine);
this.appProvider = appProvider;
this.contentEnricher = contentEnricher;
this.contentRepository = contentRepository;
this.contentVersionLoader = contentVersionLoader;
this.queryParser = queryParser;
this.scriptEngine = scriptEngine;
this.queryParser = queryParser;
}
@ -84,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity));
}
return await TransformAsync(context, schema, content);
return await TransformAsync(context, content);
}
}
@ -109,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
contents = await QueryByQueryAsync(context, schema, query);
}
return await TransformAsync(context, schema, contents);
return await TransformAsync(context, contents);
}
}
@ -124,66 +116,39 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
return EmptyContents;
}
var results = new List<IEnrichedContentEntity>();
var contents = await QueryCoreAsync(context, ids);
foreach (var group in contents.GroupBy(x => x.Schema.Id))
{
var schema = group.First().Schema;
if (HasPermission(context, schema))
{
var enriched = await TransformCoreAsync(context, schema, group.Select(x => x.Content));
var filtered =
contents
.GroupBy(x => x.Schema.Id)
.Select(g => FilterContents(g, context))
.SelectMany(c => c);
results.AddRange(enriched);
}
}
var results = await TransformCoreAsync(context, filtered);
return ResultList.Create(results.Count, results.SortList(x => x.Id, ids));
}
}
private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(Context context, ISchemaEntity schema, IResultList<IContentEntity> contents)
private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(Context context, IResultList<IContentEntity> contents)
{
var transformed = await TransformCoreAsync(context, schema, contents);
var transformed = await TransformCoreAsync(context, contents);
return ResultList.Create(contents.Total, transformed);
}
private async Task<IEnrichedContentEntity> TransformAsync(Context context, ISchemaEntity schema, IContentEntity content)
private async Task<IEnrichedContentEntity> TransformAsync(Context context, IContentEntity content)
{
var transformed = await TransformCoreAsync(context, schema, Enumerable.Repeat(content, 1));
var transformed = await TransformCoreAsync(context, Enumerable.Repeat(content, 1));
return transformed[0];
}
private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(Context context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(Context context, IEnumerable<IContentEntity> contents)
{
using (Profiler.TraceMethod<ContentQueryService>())
{
var results = new List<IEnrichedContentEntity>();
var script = schema.SchemaDef.Scripts.Query;
var scripting = !string.IsNullOrWhiteSpace(script);
var enriched = await contentEnricher.EnrichAsync(contents, context);
foreach (var content in enriched)
{
var result = SimpleMapper.Map(content, new ContentEntity());
if (result.Data != null && !context.IsFrontendClient && scripting)
{
var ctx = new ScriptContext { User = context.User, Data = content.Data, ContentId = content.Id };
result.Data = scriptEngine.Transform(ctx, script);
}
results.Add(result);
}
return results;
return await contentEnricher.EnrichAsync(contents, context);
}
}
@ -220,25 +185,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
}
}
private static bool HasPermission(Context context, ISchemaEntity schema)
private static IEnumerable<IContentEntity> FilterContents(IGrouping<Guid, (IContentEntity Content, ISchemaEntity Schema)> group, Context context)
{
var permission = Permissions.ForApp(Permissions.AppContentsRead, schema.AppId.Name, schema.SchemaDef.Name);
var schema = group.First().Schema;
return context.Permissions.Allows(permission);
}
private static Status[]? GetStatus(Context context)
{
if (context.IsFrontendClient || context.ShouldProvideUnpublished())
if (HasPermission(context, schema))
{
return null;
return group.Select(x => x.Content);
}
else
{
return StatusPublishedOnly;
return Enumerable.Empty<IContentEntity>();
}
}
private static bool HasPermission(Context context, ISchemaEntity schema)
{
var permission = Permissions.ForApp(Permissions.AppContentsRead, schema.AppId.Name, schema.SchemaDef.Name);
return context.Permissions.Allows(permission);
}
private async Task<IResultList<IContentEntity>> QueryByQueryAsync(Context context, ISchemaEntity schema, Q query)
{
var parsedQuery = queryParser.ParseQuery(context, schema, query);
@ -255,32 +222,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryCoreAsync(Context context, IReadOnlyList<Guid> ids)
{
return contentRepository.QueryAsync(context.App, GetStatus(context), new HashSet<Guid>(ids), WithDraft(context));
return contentRepository.QueryAsync(context.App, new HashSet<Guid>(ids), context.Scope());
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, ClrQuery query)
{
return contentRepository.QueryAsync(context.App, schema, GetStatus(context), context.IsFrontendClient, query, WithDraft(context));
return contentRepository.QueryAsync(context.App, schema, query, context.Scope());
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, HashSet<Guid> ids)
{
return contentRepository.QueryAsync(context.App, schema, GetStatus(context), ids, WithDraft(context));
return contentRepository.QueryAsync(context.App, schema, ids, context.Scope());
}
private Task<IContentEntity?> FindCoreAsync(Context context, Guid id, ISchemaEntity schema)
{
return contentRepository.FindContentAsync(context.App, schema, GetStatus(context), id, WithDraft(context));
return contentRepository.FindContentAsync(context.App, schema, id, context.Scope());
}
private Task<IContentEntity> FindByVersionAsync(Guid id, long version)
{
return contentVersionLoader.GetAsync(id, version);
}
private static bool WithDraft(Context context)
{
return context.ShouldProvideUnpublished() || context.IsFrontendClient;
}
}
}

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

@ -48,19 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
foreach (var content in group)
{
if (content.Data != null)
{
content.Data = content.Data.ConvertName2Name(schema.SchemaDef, converters);
}
if (content.DataDraft != null && resolveDataDraft)
{
content.DataDraft = content.DataDraft.ConvertName2Name(schema.SchemaDef, converters);
}
else
{
content.DataDraft = null!;
}
content.Data = content.Data.ConvertName2Name(schema.SchemaDef, converters);
}
}
}
@ -77,8 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
foreach (var content in group)
{
content.Data?.AddReferencedIds(schema.SchemaDef, ids);
content.DataDraft?.AddReferencedIds(schema.SchemaDef, ids);
content.Data.AddReferencedIds(schema.SchemaDef, ids);
}
}
@ -100,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
private async Task<IEnumerable<Guid>> QueryContentIdsAsync(Context context, HashSet<Guid> ids)
{
var result = await contentRepository.QueryIdsAsync(context.App.Id, ids);
var result = await contentRepository.QueryIdsAsync(context.App.Id, ids, context.ShouldProvideUnpublished() ? SearchScope.All : SearchScope.Published);
return result.Select(x => x.Id);
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs

@ -25,6 +25,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
foreach (var content in group)
{
content.IsSingleton = schema.SchemaDef.IsSingleton;
content.SchemaName = schemaName;
content.SchemaDisplayName = schemaDisplayName;
}

34
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs

@ -44,31 +44,45 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
private async Task EnrichNextsAsync(ContentEntity content, Context context)
{
content.Nexts = await contentWorkflow.GetNextsAsync(content, context.User);
var editingStatus = content.NewStatus ?? content.Status;
content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.User);
}
private async Task EnrichCanUpdateAsync( ContentEntity content, Context context)
private async Task EnrichCanUpdateAsync(ContentEntity content, Context context)
{
content.CanUpdate = await contentWorkflow.CanUpdateAsync(content, context.User);
var editingStatus = content.NewStatus ?? content.Status;
content.CanUpdate = await contentWorkflow.CanUpdateAsync(content, editingStatus, context.User);
}
private async Task EnrichColorAsync(IContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache)
private async Task EnrichColorAsync(ContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache)
{
result.StatusColor = await GetColorAsync(content, cache);
result.StatusColor = await GetColorAsync(content, content.Status, cache);
if (content.NewStatus.HasValue)
{
result.NewStatusColor = await GetColorAsync(content, content.NewStatus.Value, cache);
}
if (content.ScheduleJob != null)
{
result.ScheduledStatusColor = await GetColorAsync(content, content.ScheduleJob.Status, cache);
}
}
private async Task<string> GetColorAsync(IContentEntity content, Dictionary<(Guid, Status), StatusInfo> cache)
private async Task<string> GetColorAsync(IContentEntity content, Status status, Dictionary<(Guid, Status), StatusInfo> cache)
{
if (!cache.TryGetValue((content.SchemaId.Id, content.Status), out var info))
if (!cache.TryGetValue((content.SchemaId.Id, status), out var info))
{
info = await contentWorkflow.GetInfoAsync(content);
info = await contentWorkflow.GetInfoAsync(content, status);
if (info == null)
{
info = new StatusInfo(content.Status, DefaultColor);
info = new StatusInfo(status, DefaultColor);
}
cache[(content.SchemaId.Id, content.Status)] = info;
cache[(content.SchemaId.Id, status)] = info;
}
return info.Color;

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

@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
var fieldReference = content.ReferenceData.GetOrAdd(field.Name, _ => new ContentFieldData())!;
if (content.DataDraft.TryGetValue(field.Name, out var fieldData) && fieldData != null)
if (content.Data.TryGetValue(field.Name, out var fieldData) && fieldData != null)
{
foreach (var (partitionKey, partitionValue) in fieldData)
{
@ -118,7 +118,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
foreach (var content in contents)
{
content.DataDraft.AddReferencedIds(schema.SchemaDef.ResolvingAssets(), ids);
content.Data.AddReferencedIds(schema.SchemaDef.ResolvingAssets(), ids);
}
}

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

@ -81,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
try
{
if (content.DataDraft.TryGetValue(field.Name, out var fieldData) && fieldData != null)
if (content.Data.TryGetValue(field.Name, out var fieldData) && fieldData != null)
{
foreach (var (partition, partitionValue) in fieldData)
{
@ -123,7 +123,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
private static JsonObject Format(IContentEntity content, Context context, ISchemaEntity referencedSchema)
{
return content.DataDraft.FormatReferences(referencedSchema.SchemaDef, context.App.LanguagesConfig);
return content.Data.FormatReferences(referencedSchema.SchemaDef, context.App.LanguagesConfig);
}
private static JsonObject CreateFallback(Context context, List<IEnrichedContentEntity> referencedContents)
@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
foreach (var content in contents)
{
content.DataDraft.AddReferencedIds(schema.SchemaDef.ResolvingReferences(), ids);
content.Data.AddReferencedIds(schema.SchemaDef.ResolvingReferences(), ids);
}
}

60
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs

@ -0,0 +1,60 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
public sealed class ScriptContent : IContentEnricherStep
{
private readonly IScriptEngine scriptEngine;
public ScriptContent(IScriptEngine scriptEngine)
{
Guard.NotNull(scriptEngine, nameof(scriptEngine));
this.scriptEngine = scriptEngine;
}
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas)
{
if (ShouldEnrich(context))
{
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
var schema = await schemas(group.Key);
var script = schema.SchemaDef.Scripts.Query;
if (!string.IsNullOrWhiteSpace(script))
{
var results = new List<IEnrichedContentEntity>();
var scriptContext = new ScriptContext { User = context.User };
foreach (var content in group)
{
scriptContext.Data = content.Data;
scriptContext.ContentId = content.Id;
content.Data = scriptEngine.Transform(scriptContext, script);
}
}
}
}
}
private static bool ShouldEnrich(Context context)
{
return !context.IsFrontendClient;
}
}
}

11
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -9,7 +9,6 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
@ -19,17 +18,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
{
public interface IContentRepository
{
Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, Status[]? status, HashSet<Guid> ids, bool includeDraft);
Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<Guid> ids, SearchScope scope);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[]? status, HashSet<Guid> ids, bool includeDraft);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids, SearchScope scope);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[]? status, bool inDraft, ClrQuery query, bool includeDraft);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query, SearchScope scope);
Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode);
Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids);
Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids, SearchScope scope);
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[]? status, Guid id, bool includeDraft);
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, SearchScope scope);
Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback);
}

15
backend/src/Squidex.Domain.Apps.Entities/Contents/SearchScope.cs

@ -0,0 +1,15 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents
{
public enum SearchScope
{
All,
Published
}
}

115
backend/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Runtime.Serialization;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
@ -19,31 +18,37 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
{
public sealed class ContentState : DomainObjectState<ContentState>, IContentEntity
{
[DataMember]
public NamedId<Guid> AppId { get; set; }
[DataMember]
public NamedId<Guid> SchemaId { get; set; }
[DataMember]
public NamedContentData Data { get; set; }
public ContentVersion? NewVersion { get; set; }
[DataMember]
public NamedContentData DataDraft { get; set; }
public ContentVersion CurrentVersion { get; set; }
[DataMember]
public ScheduleJob? ScheduleJob { get; set; }
[DataMember]
public bool IsPending { get; set; }
public NamedContentData Data
{
get { return NewVersion?.Data ?? CurrentVersion.Data; }
}
[DataMember]
public bool IsDeleted { get; set; }
public Status EditingStatus
{
get { return NewStatus ?? Status; }
}
[DataMember]
public Status Status { get; set; }
public Status Status
{
get { return CurrentVersion.Status; }
}
public override bool ApplyEvent(IEvent @event)
public Status? NewStatus
{
get { return NewVersion?.Status; }
}
public override bool ApplyEvent(IEvent @event, EnvelopeHeaders headers)
{
switch (@event)
{
@ -51,51 +56,48 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
{
SimpleMapper.Map(e, this);
UpdateData(null, e.Data, false);
CurrentVersion = new ContentVersion(e.Status, e.Data);
break;
}
case ContentChangesPublished _:
case ContentDraftCreated e:
{
ScheduleJob = null;
UpdateData(DataDraft, null, false);
break;
}
NewVersion = new ContentVersion(e.Status, CurrentVersion.Data);
case ContentStatusChanged e:
{
ScheduleJob = null;
SimpleMapper.Map(e, this);
if (e.Status == Status.Published)
{
UpdateData(DataDraft, null, false);
}
break;
}
case ContentUpdated e:
case ContentDraftDeleted _:
{
UpdateData(e.Data, e.Data, false);
break;
}
NewVersion = null;
case ContentUpdateProposed e:
{
UpdateData(null, e.Data, true);
ScheduleJob = null;
break;
}
case ContentChangesDiscarded _:
case ContentStatusChanged e:
{
UpdateData(null, Data, false);
if (NewVersion != null)
{
if (e.Status == Status.Published)
{
CurrentVersion = new ContentVersion(e.Status, NewVersion.Data);
NewVersion = null;
}
else
{
NewVersion = NewVersion.WithStatus(e.Status);
}
}
else
{
CurrentVersion = CurrentVersion.WithStatus(e.Status);
}
break;
}
@ -114,6 +116,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
break;
}
case ContentUpdated e:
{
if (NewVersion != null)
{
NewVersion = NewVersion.WithData(e.Data);
}
else
{
CurrentVersion = CurrentVersion.WithData(e.Data);
}
break;
}
case ContentDeleted _:
{
IsDeleted = true;
@ -124,20 +140,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
return true;
}
private void UpdateData(NamedContentData? data, NamedContentData? dataDraft, bool isPending)
{
if (data != null)
{
Data = data;
}
if (dataDraft != null)
{
DataDraft = dataDraft;
}
IsPending = isPending;
}
}
}

38
backend/src/Squidex.Domain.Apps.Entities/Contents/State/ContentVersion.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.State
{
public sealed class ContentVersion
{
public Status Status { get; }
public NamedContentData Data { get; }
public ContentVersion(Status status, NamedContentData data)
{
Guard.NotNull(data);
Status = status;
Data = data;
}
public ContentVersion WithStatus(Status status)
{
return new ContentVersion(status, Data);
}
public ContentVersion WithData(NamedContentData data)
{
return new ContentVersion(Status, data);
}
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Scope.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/DeleteIndexEntry.cs

@ -7,9 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public enum Scope
public sealed class DeleteIndexEntry : IndexCommand
{
Draft,
Published
}
}

198
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs

@ -0,0 +1,198 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Elasticsearch.Net;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic
{
[ExcludeFromCodeCoverage]
public sealed class ElasticSearchTextIndex : IContentTextIndex
{
private const string IndexName = "contents";
private readonly ElasticLowLevelClient client;
public ElasticSearchTextIndex()
{
var config = new ConnectionConfiguration(new Uri("http://localhost:9200"));
client = new ElasticLowLevelClient(config);
}
public async Task ExecuteAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, params IndexCommand[] commands)
{
foreach (var command in commands)
{
switch (command)
{
case UpsertIndexEntry upsert:
await UpsertAsync(appId, schemaId, upsert);
break;
case UpdateIndexEntry update:
await UpdateAsync(update);
break;
case DeleteIndexEntry delete:
await DeleteAsync(delete);
break;
}
}
}
private async Task UpsertAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, UpsertIndexEntry upsert)
{
upsert.Texts["en"] = "Foo";
var data = new
{
appId = appId.Id,
appName = appId.Name,
contentId = upsert.ContentId,
schemaId = schemaId.Id,
schemaName = schemaId.Name,
serveAll = upsert.ServeAll,
servePublished = upsert.ServePublished,
texts = upsert.Texts,
};
var result = await client.IndexAsync<StringResponse>(IndexName, upsert.DocId, CreatePost(data));
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
private async Task UpdateAsync(UpdateIndexEntry update)
{
var data = new
{
doc = new
{
update.ServeAll,
update.ServePublished
}
};
var result = await client.UpdateAsync<StringResponse>(IndexName, update.DocId, CreatePost(data));
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
private Task DeleteAsync(DeleteIndexEntry delete)
{
return client.DeleteAsync<StringResponse>(IndexName, delete.DocId);
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
}
public async Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope)
{
var serveField = GetServeField(scope);
var query = new
{
query = new
{
@bool = new
{
must = new List<object>
{
new
{
term = new Dictionary<string, object>
{
["appId.keyword"] = app.Id
}
},
new
{
term = new Dictionary<string, string>
{
[serveField] = "true"
}
},
new
{
multi_match = new
{
fields = new[]
{
"texts.*"
},
query = queryText
}
}
},
should = new List<object>()
}
},
_source = new[]
{
"contentId"
},
size = 2000
};
if (filter?.SchemaIds.Count > 0)
{
var bySchema = new
{
term = new Dictionary<string, object>
{
["schemaId.keyword"] = filter.SchemaIds
}
};
if (filter.Must)
{
query.query.@bool.must.Add(bySchema);
}
else
{
query.query.@bool.should.Add(bySchema);
}
}
var result = await client.SearchAsync<DynamicResponse>(IndexName, CreatePost(query));
if (!result.Success)
{
throw result.OriginalException;
}
var ids = new List<Guid>();
foreach (var item in result.Body.hits.hits)
{
if (item != null)
{
ids.Add(Guid.Parse(item["_source"]["contentId"]));
}
}
return ids;
}
private static string GetServeField(SearchScope scope)
{
return scope == SearchScope.Published ?
"servePublished" :
"serveAll";
}
}
}

54
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs

@ -8,9 +8,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.Util;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
@ -19,20 +16,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public static class Extensions
{
public static void SetBinaryDocValue(this Document document, string name, BytesRef value)
{
document.RemoveField(name);
document.AddBinaryDocValuesField(name, value);
}
public static void SetField(this Document document, string name, string value)
{
document.RemoveField(name);
document.AddStringField(name, value, Field.Store.YES);
}
public static Dictionary<string, string> ToTexts(this NamedContentData data)
{
var result = new Dictionary<string, string>();
@ -99,42 +82,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
}
}
}
public static BytesRef GetBinaryValue(this IndexReader? reader, string field, int docId, BytesRef? result = null)
{
if (result != null)
{
Array.Clear(result.Bytes, 0, result.Bytes.Length);
}
else
{
result = new BytesRef();
}
if (reader == null || docId < 0)
{
return result;
}
var leaves = reader.Leaves;
if (leaves.Count == 1)
{
var docValues = leaves[0].AtomicReader.GetBinaryDocValues(field);
docValues.Get(docId, result);
}
else if (leaves.Count > 1)
{
var subIndex = ReaderUtil.SubIndex(docId, leaves);
var subLeave = leaves[subIndex];
var subValues = subLeave.AtomicReader.GetBinaryDocValues(field);
subValues.Get(docId - subLeave.DocBase, result);
}
return result;
}
}
}

117
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GrainTextIndexer.cs

@ -1,117 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public sealed class GrainTextIndexer : ITextIndexer, IEventConsumer
{
private readonly IGrainFactory grainFactory;
public string Name
{
get { return "TextIndexer2"; }
}
public string EventsFilter
{
get { return "^content-"; }
}
public GrainTextIndexer(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory);
this.grainFactory = grainFactory;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return TaskHelper.Done;
}
public async Task On(Envelope<IEvent> @event)
{
if (@event.Payload is ContentEvent contentEvent)
{
var index = grainFactory.GetGrain<ITextIndexerGrain>(contentEvent.SchemaId.Id);
var id = contentEvent.ContentId;
switch (@event.Payload)
{
case ContentDeleted _:
await index.DeleteAsync(id);
break;
case ContentCreated contentCreated:
await index.IndexAsync(Data(id, contentCreated.Data, true));
break;
case ContentUpdateProposed contentUpdateProposed:
await index.IndexAsync(Data(id, contentUpdateProposed.Data, true));
break;
case ContentUpdated contentUpdated:
await index.IndexAsync(Data(id, contentUpdated.Data, false));
break;
case ContentChangesDiscarded _:
await index.CopyAsync(id, false);
break;
case ContentChangesPublished _:
await index.CopyAsync(id, true);
break;
case ContentStatusChanged contentStatusChanged when contentStatusChanged.Status == Status.Published:
await index.CopyAsync(id, true);
break;
}
}
}
private static Update Data(Guid contentId, NamedContentData data, bool onlyDraft)
{
return new Update { Id = contentId, Text = data.ToTexts(), OnlyDraft = onlyDraft };
}
public async Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, Guid schemaId, Scope scope = Scope.Published)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return null;
}
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId);
using (Profiler.TraceMethod<GrainTextIndexer>())
{
var context = CreateContext(app, scope);
return await index.SearchAsync(queryText, context);
}
}
private static SearchContext CreateContext(IAppEntity app, Scope scope)
{
var languages = new HashSet<string>(app.LanguagesConfig.AllKeys);
return new SearchContext { Languages = languages, Scope = scope };
}
}
}

7
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndexer.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IContentTextIndex.cs

@ -9,11 +9,14 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public interface ITextIndexer
public interface IContentTextIndex
{
Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, Guid schemaId, Scope scope = Scope.Published);
Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope);
Task ExecuteAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, params IndexCommand[] commands);
}
}

14
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexCommand.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public abstract class IndexCommand
{
public string DocId { get; set; }
}
}

114
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexState.cs

@ -1,114 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.Search;
using Lucene.Net.Util;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
internal sealed class IndexState
{
private const string MetaFor = "_fd";
private readonly Dictionary<(Guid, Scope), (bool, bool)> lastChanges = new Dictionary<(Guid, Scope), (bool, bool)>();
private readonly BytesRef bytesRef = new BytesRef(2);
private readonly IIndex index;
public IndexState(IIndex index)
{
this.index = index;
}
public void Index(Guid id, Scope scope, Document document, bool forDraft, bool forPublished)
{
var value = GetValue(forDraft, forPublished);
document.SetBinaryDocValue(MetaFor, value);
lastChanges[(id, scope)] = (forDraft, forPublished);
}
public void Index(Guid id, Scope scope, Term term, bool forDraft, bool forPublished)
{
var value = GetValue(forDraft, forPublished);
index.Writer.UpdateBinaryDocValue(term, MetaFor, value);
lastChanges[(id, scope)] = (forDraft, forPublished);
}
public bool HasBeenAdded(Guid id, Scope scope, Term term, out int docId)
{
docId = -1;
if (lastChanges.ContainsKey((id, scope)))
{
return true;
}
if (index.Searcher == null)
{
return false;
}
var docs = index.Searcher.Search(new TermQuery(term), 1);
var found = docs.ScoreDocs.FirstOrDefault();
if (found != null)
{
docId = found.Doc;
return true;
}
return false;
}
public void Get(Guid id, Scope scope, int docId, out bool forDraft, out bool forPublished)
{
if (lastChanges.TryGetValue((id, scope), out var forValue))
{
(forDraft, forPublished) = forValue;
}
else
{
Get(docId, out forDraft, out forPublished);
}
}
public void Get(int docId, out bool forDraft, out bool forPublished)
{
var forValue = GetForValues(docId);
(forDraft, forPublished) = ToFlags(forValue);
}
private BytesRef GetForValues(int docId)
{
return index.Reader.GetBinaryValue(MetaFor, docId, bytesRef);
}
private static BytesRef GetValue(bool forDraft, bool forPublished)
{
return new BytesRef(new[]
{
(byte)(forDraft ? 1 : 0),
(byte)(forPublished ? 1 : 0)
});
}
private static (bool, bool) ToFlags(BytesRef bytes)
{
return (bytes.Bytes[0] == 1, bytes.Bytes[1] == 1);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IIndex.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IIndex.cs

@ -9,7 +9,7 @@ using Lucene.Net.Analysis;
using Lucene.Net.Index;
using Lucene.Net.Search;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public interface IIndex
{

14
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndexerGrain.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/ILuceneTextIndexGrain.cs

@ -9,17 +9,15 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public interface ITextIndexerGrain : IGrainWithGuidKey
public interface ILuceneTextIndexGrain : IGrainWithGuidKey
{
Task<bool> DeleteAsync(Guid id);
Task IndexAsync(NamedId<Guid> schemaId, Immutable<IndexCommand[]> updates);
Task<bool> CopyAsync(Guid id, bool fromDraft);
Task<bool> IndexAsync(Update update);
Task<List<Guid>> SearchAsync(string queryText, SearchContext context);
Task<List<Guid>> SearchAsync(string queryText, SearchFilter? filter, SearchContext context);
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexManager.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs

@ -13,7 +13,7 @@ using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed partial class IndexManager : DisposableObjectBase
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IndexManager_Impl.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs

@ -13,7 +13,7 @@ using Lucene.Net.Store;
using Lucene.Net.Util;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed partial class IndexManager
{

53
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneExtensions.cs

@ -0,0 +1,53 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Lucene.Net.Index;
using Lucene.Net.Util;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public static class LuceneExtensions
{
public static BytesRef GetBinaryValue(this IndexReader? reader, string field, int docId, BytesRef? result = null)
{
if (result != null)
{
Array.Clear(result.Bytes, 0, result.Bytes.Length);
}
else
{
result = new BytesRef();
}
if (reader == null || docId < 0)
{
return result;
}
var leaves = reader.Leaves;
if (leaves.Count == 1)
{
var docValues = leaves[0].AtomicReader.GetBinaryDocValues(field);
docValues.Get(docId, result);
}
else if (leaves.Count > 1)
{
var subIndex = ReaderUtil.SubIndex(docId, leaves);
var subLeave = leaves[subIndex];
var subValues = subLeave.AtomicReader.GetBinaryDocValues(field);
subValues.Get(docId - subLeave.DocBase, result);
}
return result;
}
}
}

61
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndex.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed class LuceneTextIndex : IContentTextIndex
{
private readonly IGrainFactory grainFactory;
public LuceneTextIndex(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory);
this.grainFactory = grainFactory;
}
public async Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return null;
}
var index = grainFactory.GetGrain<ILuceneTextIndexGrain>(app.Id);
using (Profiler.TraceMethod<LuceneTextIndex>())
{
var context = CreateContext(app, scope);
return await index.SearchAsync(queryText, filter, context);
}
}
private static SearchContext CreateContext(IAppEntity app, SearchScope scope)
{
var languages = new HashSet<string>(app.LanguagesConfig.AllKeys);
return new SearchContext { Languages = languages, Scope = scope };
}
public Task ExecuteAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, params IndexCommand[] commands)
{
var index = grainFactory.GetGrain<ILuceneTextIndexGrain>(appId.Id);
return index.IndexAsync(schemaId, commands.AsImmutable());
}
}
}

253
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/LuceneTextIndexGrain.cs

@ -0,0 +1,253 @@
// ==========================================================================
// 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 Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed class LuceneTextIndexGrain : GrainOfGuid, ILuceneTextIndexGrain
{
private const LuceneVersion Version = LuceneVersion.LUCENE_48;
private const int MaxResults = 2000;
private const int MaxUpdates = 400;
private const string MetaId = "_id";
private const string MetaFor = "_fd";
private const string MetaContentId = "_cid";
private const string MetaSchemaId = "_si";
private const string MetaSchemaName = "_sn";
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(10);
private static readonly string[] Invariant = { InvariantPartitioning.Key };
private readonly IndexManager indexManager;
private IDisposable? timer;
private IIndex index;
private QueryParser? queryParser;
private HashSet<string>? currentLanguages;
private int updates;
public LuceneTextIndexGrain(IndexManager indexManager)
{
Guard.NotNull(indexManager);
this.indexManager = indexManager;
}
public override async Task OnDeactivateAsync()
{
if (index != null)
{
await CommitAsync();
await indexManager.ReleaseAsync(index);
}
}
protected override async Task OnActivateAsync(Guid key)
{
index = await indexManager.AcquireAsync(key);
}
public Task<List<Guid>> SearchAsync(string queryText, SearchFilter? filter, SearchContext context)
{
var result = new List<Guid>();
if (!string.IsNullOrWhiteSpace(queryText))
{
index.EnsureReader();
if (index.Searcher != null)
{
var query = BuildQuery(queryText, filter, context);
var hits = index.Searcher.Search(query, MaxResults).ScoreDocs;
if (hits.Length > 0)
{
var buffer = new BytesRef(2);
var found = new HashSet<Guid>();
foreach (var hit in hits)
{
var forValue = index.Reader.GetBinaryValue(MetaFor, hit.Doc, buffer);
if (context.Scope == SearchScope.All && forValue.Bytes[0] != 1)
{
continue;
}
if (context.Scope == SearchScope.Published && forValue.Bytes[1] != 1)
{
continue;
}
var document = index.Searcher.Doc(hit.Doc);
if (document != null)
{
var idString = document.Get(MetaContentId);
if (Guid.TryParse(idString, out var id))
{
if (found.Add(id))
{
result.Add(id);
}
}
}
}
}
}
}
return Task.FromResult(result);
}
private Query BuildQuery(string query, SearchFilter? filter, SearchContext context)
{
if (queryParser == null || currentLanguages == null || !currentLanguages.SetEquals(context.Languages))
{
var fields = context.Languages.Union(Invariant).ToArray();
queryParser = new MultiFieldQueryParser(Version, fields, index.Analyzer);
currentLanguages = context.Languages;
}
try
{
var byQuery = queryParser.Parse(query);
if (filter?.SchemaIds.Count > 0)
{
var bySchemas = new BooleanQuery
{
Boost = 2f
};
foreach (var schemaId in filter.SchemaIds)
{
var term = new Term(MetaSchemaId, schemaId.ToString());
bySchemas.Add(new TermQuery(term), Occur.SHOULD);
}
var occur = filter.Must ? Occur.MUST : Occur.SHOULD;
return new BooleanQuery
{
{ byQuery, Occur.MUST },
{ bySchemas, occur }
};
}
return byQuery;
}
catch (ParseException ex)
{
throw new ValidationException(ex.Message);
}
}
private async Task<bool> TryCommitAsync()
{
timer?.Dispose();
updates++;
if (updates >= MaxUpdates)
{
await CommitAsync();
return true;
}
else
{
index.MarkStale();
try
{
timer = RegisterTimer(_ => CommitAsync(), null, CommitDelay, CommitDelay);
}
catch (InvalidOperationException)
{
return false;
}
}
return false;
}
public async Task CommitAsync()
{
if (updates > 0)
{
await indexManager.CommitAsync(index);
updates = 0;
}
}
public Task IndexAsync(NamedId<Guid> schemaId, Immutable<IndexCommand[]> updates)
{
foreach (var command in updates.Value)
{
switch (command)
{
case DeleteIndexEntry delete:
index.Writer.DeleteDocuments(new Term(MetaId, delete.DocId));
break;
case UpdateIndexEntry update:
index.Writer.UpdateBinaryDocValue(new Term(MetaId, update.DocId), MetaFor, GetValue(update.ServeAll, update.ServePublished));
break;
case UpsertIndexEntry upsert:
{
var document = new Document();
document.AddStringField(MetaId, upsert.DocId, Field.Store.YES);
document.AddStringField(MetaContentId, upsert.ContentId.ToString(), Field.Store.YES);
document.AddStringField(MetaSchemaId, schemaId.Id.ToString(), Field.Store.YES);
document.AddStringField(MetaSchemaName, schemaId.Name, Field.Store.YES);
document.AddBinaryDocValuesField(MetaFor, GetValue(upsert.ServeAll, upsert.ServePublished));
foreach (var (key, value) in upsert.Texts)
{
document.AddTextField(key, value, Field.Store.NO);
}
index.Writer.UpdateDocument(new Term(MetaId, upsert.DocId), document);
break;
}
}
}
return TryCommitAsync();
}
private static BytesRef GetValue(bool forDraft, bool forPublished)
{
return new BytesRef(new[]
{
(byte)(forDraft ? 1 : 0),
(byte)(forPublished ? 1 : 0)
});
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/MultiLanguageAnalyzer.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/MultiLanguageAnalyzer.cs

@ -13,7 +13,7 @@ using Lucene.Net.Analysis.Standard;
using Lucene.Net.Util;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public sealed class MultiLanguageAnalyzer : AnalyzerWrapper
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/AssetIndexStorage.cs

@ -15,7 +15,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using LuceneDirectory = Lucene.Net.Store.Directory;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene.Storage
{
public sealed class AssetIndexStorage : IIndexStorage
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/FileIndexStorage.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/FileIndexStorage.cs

@ -12,7 +12,7 @@ using Lucene.Net.Index;
using Lucene.Net.Store;
using LuceneDirectory = Lucene.Net.Store.Directory;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene.Storage
{
public sealed class FileIndexStorage : IIndexStorage
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/IIndexStorage.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/Storage/IIndexStorage.cs

@ -10,7 +10,7 @@ using System.Threading.Tasks;
using Lucene.Net.Index;
using Lucene.Net.Store;
namespace Squidex.Domain.Apps.Entities.Contents.Text
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene
{
public interface IIndexStorage
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchContext.cs

@ -11,7 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public sealed class SearchContext
{
public Scope Scope { get; set; }
public SearchScope Scope { get; set; }
public HashSet<string> Languages { get; set; }
}

46
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs

@ -0,0 +1,46 @@
// ==========================================================================
// 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 Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
[Equals(DoNotAddEqualityOperators = true)]
public sealed class SearchFilter
{
public IReadOnlyList<Guid> SchemaIds { get; }
public bool Must { get; }
public SearchFilter(IReadOnlyList<Guid> schemaIds, bool must)
{
Guard.NotNull(schemaIds);
SchemaIds = schemaIds;
Must = must;
}
public static SearchFilter MustHaveSchemas(List<Guid> schemaIds)
{
return new SearchFilter(schemaIds, true);
}
public static SearchFilter MustHaveSchemas(params Guid[] schemaIds)
{
return new SearchFilter(schemaIds?.ToList()!, true);
}
public static SearchFilter ShouldHaveSchemas(params Guid[] schemaIds)
{
return new SearchFilter(schemaIds?.ToList()!, false);
}
}
}

64
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/CachingTextIndexerState.cs

@ -0,0 +1,64 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
public sealed class CachingTextIndexerState : ITextIndexerState
{
private readonly ITextIndexerState inner;
private LRUCache<Guid, Tuple<TextContentState?>> cache = new LRUCache<Guid, Tuple<TextContentState?>>(1000);
public CachingTextIndexerState(ITextIndexerState inner)
{
Guard.NotNull(inner);
this.inner = inner;
}
public async Task ClearAsync()
{
await inner.ClearAsync();
cache = new LRUCache<Guid, Tuple<TextContentState?>>(1000);
}
public async Task<TextContentState?> GetAsync(Guid contentId)
{
if (cache.TryGetValue(contentId, out var value))
{
return value.Item1;
}
var result = await inner.GetAsync(contentId);
cache.Set(contentId, Tuple.Create(result));
return result;
}
public Task SetAsync(TextContentState state)
{
Guard.NotNull(state);
cache.Set(state.ContentId, Tuple.Create<TextContentState?>(state));
return inner.SetAsync(state);
}
public Task RemoveAsync(Guid contentId)
{
cache.Set(contentId, Tuple.Create<TextContentState?>(null));
return inner.RemoveAsync(contentId);
}
}
}

23
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/ITextIndexerState.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
public interface ITextIndexerState
{
Task<TextContentState?> GetAsync(Guid contentId);
Task SetAsync(TextContentState state);
Task RemoveAsync(Guid contentId);
Task ClearAsync();
}
}

50
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/InMemoryTextIndexerState.cs

@ -0,0 +1,50 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
public sealed class InMemoryTextIndexerState : ITextIndexerState
{
private readonly Dictionary<Guid, TextContentState> states = new Dictionary<Guid, TextContentState>();
public Task ClearAsync()
{
states.Clear();
return TaskHelper.Done;
}
public Task<TextContentState?> GetAsync(Guid contentId)
{
if (states.TryGetValue(contentId, out var result))
{
return Task.FromResult<TextContentState?>(result);
}
return Task.FromResult<TextContentState?>(null);
}
public Task RemoveAsync(Guid contentId)
{
states.Remove(contentId);
return TaskHelper.Done;
}
public Task SetAsync(TextContentState state)
{
states[state.ContentId] = state;
return TaskHelper.Done;
}
}
}

46
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/State/TextContentState.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Domain.Apps.Entities.Contents.Text.State
{
public sealed class TextContentState
{
public Guid ContentId { get; set; }
public string DocIdCurrent { get; set; }
public string? DocIdNew { get; set; }
public string? DocIdForPublished { get; set; }
public void GenerateDocIdNew()
{
if (DocIdCurrent?.EndsWith("_2") != false)
{
DocIdNew = $"{ContentId}_1";
}
else
{
DocIdNew = $"{ContentId}_2";
}
}
public void GenerateDocIdCurrent()
{
if (DocIdNew?.EndsWith("_2") != false)
{
DocIdCurrent = $"{ContentId}_1";
}
else
{
DocIdCurrent = $"{ContentId}_2";
}
}
}
}

172
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexContent.cs

@ -1,172 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using Lucene.Net.Documents;
using Lucene.Net.Index;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
internal sealed class TextIndexContent
{
private const string MetaId = "_id";
private const string MetaKey = "_key";
private readonly IIndex index;
private readonly IndexState indexState;
private readonly Guid id;
public TextIndexContent(IIndex index, IndexState indexState, Guid id)
{
this.index = index;
this.indexState = indexState;
this.id = id;
}
public void Delete()
{
index.Writer.DeleteDocuments(new Term(MetaId, id.ToString()));
}
public static bool TryGetId(int docId, Scope scope, IIndex index, IndexState indexState, out Guid result)
{
result = Guid.Empty;
indexState.Get(docId, out var draft, out var published);
if (scope == Scope.Draft && !draft)
{
return false;
}
if (scope == Scope.Published && !published)
{
return false;
}
var document = index.Searcher?.Doc(docId);
if (document != null)
{
var idString = document.Get(MetaId);
if (!Guid.TryParse(idString, out result))
{
return false;
}
}
return true;
}
public void Index(Dictionary<string, string> text, bool onlyDraft)
{
var converted = CreateDocument(text);
Upsert(converted, Scope.Draft,
forDraft: true,
forPublished: false);
var isPublishDocumentAdded = IsAdded(Scope.Published, out var docId);
var isPublishForPublished = IsForPublished(Scope.Published, docId);
if (!onlyDraft && isPublishDocumentAdded && isPublishForPublished)
{
Upsert(converted, Scope.Published,
forDraft: false,
forPublished: true);
}
else if (!onlyDraft || !isPublishDocumentAdded)
{
Upsert(converted, Scope.Published,
forDraft: false,
forPublished: false);
}
else
{
UpdateFor(Scope.Published,
forDraft: false,
forPublished: isPublishForPublished);
}
}
public void Copy(bool fromDraft)
{
if (fromDraft)
{
UpdateFor(Scope.Draft,
forDraft: true,
forPublished: false);
UpdateFor(Scope.Published,
forDraft: false,
forPublished: true);
}
else
{
UpdateFor(Scope.Draft,
forDraft: false,
forPublished: false);
UpdateFor(Scope.Published,
forDraft: true,
forPublished: true);
}
}
private static Document CreateDocument(Dictionary<string, string> text)
{
var document = new Document();
foreach (var (key, value) in text)
{
document.AddTextField(key, value, Field.Store.NO);
}
return document;
}
private void UpdateFor(Scope scope, bool forDraft, bool forPublished)
{
var term = new Term(MetaKey, BuildKey(scope));
indexState.Index(id, scope, term, forDraft, forPublished);
}
private void Upsert(Document document, Scope draft, bool forDraft, bool forPublished)
{
var contentKey = BuildKey(draft);
document.SetField(MetaId, id.ToString());
document.SetField(MetaKey, contentKey);
indexState.Index(id, draft, document, forDraft, forPublished);
index.Writer.UpdateDocument(new Term(MetaKey, contentKey), document);
}
private bool IsAdded(Scope scope, out int docId)
{
var term = new Term(MetaKey, BuildKey(scope));
return indexState.HasBeenAdded(id, scope, term, out docId);
}
private bool IsForPublished(Scope scope, int docId)
{
indexState.Get(id, scope, docId, out _, out var forPublished);
return forPublished;
}
private string BuildKey(Scope scope)
{
return $"{id}_{(scope == Scope.Draft ? 1 : 0)}";
}
}
}

181
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexerGrain.cs

@ -1,181 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public sealed class TextIndexerGrain : GrainOfGuid, ITextIndexerGrain
{
private const LuceneVersion Version = LuceneVersion.LUCENE_48;
private const int MaxResults = 2000;
private const int MaxUpdates = 400;
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(10);
private static readonly string[] Invariant = { InvariantPartitioning.Key };
private readonly IndexManager indexManager;
private IDisposable? timer;
private IIndex index;
private IndexState indexState;
private QueryParser? queryParser;
private HashSet<string>? currentLanguages;
private int updates;
public TextIndexerGrain(IndexManager indexManager)
{
Guard.NotNull(indexManager);
this.indexManager = indexManager;
}
public override async Task OnDeactivateAsync()
{
if (index != null)
{
await indexManager.ReleaseAsync(index);
}
}
protected override async Task OnActivateAsync(Guid key)
{
index = await indexManager.AcquireAsync(key);
indexState = new IndexState(index);
}
public Task<bool> IndexAsync(Update update)
{
var content = new TextIndexContent(index, indexState, update.Id);
content.Index(update.Text, update.OnlyDraft);
return TryCommitAsync();
}
public Task<bool> CopyAsync(Guid id, bool fromDraft)
{
var content = new TextIndexContent(index, indexState, id);
content.Copy(fromDraft);
return TryCommitAsync();
}
public Task<bool> DeleteAsync(Guid id)
{
var content = new TextIndexContent(index, indexState, id);
content.Delete();
return TryCommitAsync();
}
public Task<List<Guid>> SearchAsync(string queryText, SearchContext context)
{
var result = new List<Guid>();
if (!string.IsNullOrWhiteSpace(queryText))
{
index.EnsureReader();
if (index.Searcher != null)
{
var query = BuildQuery(queryText, context);
var hits = index.Searcher.Search(query, MaxResults).ScoreDocs;
if (hits.Length > 0)
{
var found = new HashSet<Guid>();
foreach (var hit in hits)
{
if (TextIndexContent.TryGetId(hit.Doc, context.Scope, index, indexState, out var id))
{
if (found.Add(id))
{
result.Add(id);
}
}
}
}
}
}
return Task.FromResult(result);
}
private Query BuildQuery(string query, SearchContext context)
{
if (queryParser == null || currentLanguages == null || !currentLanguages.SetEquals(context.Languages))
{
var fields = context.Languages.Union(Invariant).ToArray();
queryParser = new MultiFieldQueryParser(Version, fields, index.Analyzer);
currentLanguages = context.Languages;
}
try
{
return queryParser.Parse(query);
}
catch (ParseException ex)
{
throw new ValidationException(ex.Message);
}
}
private async Task<bool> TryCommitAsync()
{
timer?.Dispose();
updates++;
if (updates >= MaxUpdates)
{
await CommitAsync();
return true;
}
else
{
index.MarkStale();
try
{
timer = RegisterTimer(_ => CommitAsync(), null, CommitDelay, CommitDelay);
}
catch (InvalidOperationException)
{
return false;
}
}
return false;
}
public async Task CommitAsync()
{
if (updates > 0)
{
await indexManager.CommitAsync(index);
updates = 0;
}
}
}
}

277
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs

@ -0,0 +1,277 @@
// ==========================================================================
// 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.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public sealed class TextIndexingProcess : IEventConsumer
{
private const string NotFound = "<404>";
private readonly IContentTextIndex textIndexer;
private readonly ITextIndexerState textIndexerState;
public string Name
{
get { return "TextIndexer2"; }
}
public string EventsFilter
{
get { return "^content-"; }
}
public IContentTextIndex TextIndexer
{
get { return textIndexer; }
}
public TextIndexingProcess(IContentTextIndex textIndexer, ITextIndexerState textIndexerState)
{
Guard.NotNull(textIndexer);
Guard.NotNull(textIndexerState);
this.textIndexer = textIndexer;
this.textIndexerState = textIndexerState;
}
public bool Handles(StoredEvent @event)
{
return true;
}
public Task ClearAsync()
{
return textIndexerState.ClearAsync();
}
public async Task On(Envelope<IEvent> @event)
{
switch (@event.Payload)
{
case ContentCreated created:
await CreateAsync(created);
break;
case ContentUpdated updated:
await UpdateAsync(updated);
break;
case ContentStatusChanged statusChanged when statusChanged.Status == Status.Published:
await PublishAsync(statusChanged);
break;
case ContentStatusChanged statusChanged:
await UnpublishAsync(statusChanged);
break;
case ContentDraftCreated draftCreated:
await CreateDraftAsync(draftCreated);
break;
case ContentDraftDeleted draftDelted:
await DeleteDraftAsync(draftDelted);
break;
case ContentDeleted deleted:
await DeleteAsync(deleted);
break;
}
}
private async Task CreateAsync(ContentCreated @event)
{
var state = new TextContentState
{
ContentId = @event.ContentId
};
state.GenerateDocIdCurrent();
await IndexAsync(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = false,
Texts = @event.Data.ToTexts(),
});
await textIndexerState.SetAsync(state);
}
private async Task CreateDraftAsync(ContentDraftCreated @event)
{
var state = await textIndexerState.GetAsync(@event.ContentId);
if (state != null)
{
state.GenerateDocIdNew();
await textIndexerState.SetAsync(state);
}
}
private async Task UpdateAsync(ContentUpdated @event)
{
var state = await textIndexerState.GetAsync(@event.ContentId);
if (state != null)
{
if (state.DocIdNew != null)
{
await IndexAsync(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdNew,
ServeAll = true,
ServePublished = false,
Texts = @event.Data.ToTexts()
},
new UpdateIndexEntry
{
DocId = state.DocIdCurrent,
ServeAll = false,
ServePublished = true
});
state.DocIdForPublished = state.DocIdCurrent;
}
else
{
var isPublished = state.DocIdCurrent == state.DocIdForPublished;
await IndexAsync(@event,
new UpsertIndexEntry
{
ContentId = @event.ContentId,
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = isPublished,
Texts = @event.Data.ToTexts()
});
state.DocIdForPublished = state.DocIdNew;
}
await textIndexerState.SetAsync(state);
}
}
private async Task UnpublishAsync(ContentStatusChanged @event)
{
var state = await textIndexerState.GetAsync(@event.ContentId);
if (state != null && state.DocIdForPublished != null)
{
await IndexAsync(@event,
new UpdateIndexEntry
{
DocId = state.DocIdForPublished,
ServeAll = true,
ServePublished = false
});
state.DocIdForPublished = null;
await textIndexerState.SetAsync(state);
}
}
private async Task PublishAsync(ContentStatusChanged @event)
{
var state = await textIndexerState.GetAsync(@event.ContentId);
if (state != null)
{
if (state.DocIdNew != null)
{
await IndexAsync(@event,
new UpdateIndexEntry
{
DocId = state.DocIdNew,
ServeAll = true,
ServePublished = true
},
new DeleteIndexEntry
{
DocId = state.DocIdCurrent
});
state.DocIdForPublished = state.DocIdNew;
state.DocIdCurrent = state.DocIdNew;
}
else
{
await IndexAsync(@event,
new UpdateIndexEntry
{
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = true
});
state.DocIdForPublished = state.DocIdCurrent;
}
state.DocIdNew = null;
await textIndexerState.SetAsync(state);
}
}
private async Task DeleteDraftAsync(ContentDraftDeleted @event)
{
var state = await textIndexerState.GetAsync(@event.ContentId);
if (state != null && state.DocIdNew != null)
{
await IndexAsync(@event,
new UpdateIndexEntry
{
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = true
},
new DeleteIndexEntry
{
DocId = state.DocIdNew,
});
state.DocIdNew = null;
await textIndexerState.SetAsync(state);
}
}
private async Task DeleteAsync(ContentDeleted @event)
{
var state = await textIndexerState.GetAsync(@event.ContentId);
if (state != null)
{
await IndexAsync(@event,
new DeleteIndexEntry
{
DocId = state.DocIdCurrent
},
new DeleteIndexEntry
{
DocId = state.DocIdNew ?? NotFound,
});
await textIndexerState.RemoveAsync(state.ContentId);
}
}
private Task IndexAsync(ContentEvent @event, params IndexCommand[] commands)
{
return textIndexer.ExecuteAsync(@event.AppId, @event.SchemaId, commands);
}
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
public sealed class UpdateIndexEntry : IndexCommand
{
public bool ServeAll { get; set; }
public bool ServePublished { get; set; }
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Update.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs

@ -7,17 +7,17 @@
using System;
using System.Collections.Generic;
using Orleans.Concurrency;
namespace Squidex.Domain.Apps.Entities.Contents.Text
{
[Immutable]
public sealed class Update
public sealed class UpsertIndexEntry : IndexCommand
{
public Guid Id { get; set; }
public Dictionary<string, string> Texts { get; set; }
public Dictionary<string, string> Text { get; set; }
public bool ServeAll { get; set; }
public bool OnlyDraft { get; set; }
public bool ServePublished { get; set; }
public Guid ContentId { get; set; }
}
}

28
backend/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Runtime.Serialization;
using NodaTime;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
@ -23,25 +22,34 @@ namespace Squidex.Domain.Apps.Entities
IEntityWithVersion
where T : class
{
[DataMember]
public Guid Id { get; set; }
[DataMember]
public RefToken CreatedBy { get; set; }
[DataMember]
public RefToken LastModifiedBy { get; set; }
[DataMember]
public Instant Created { get; set; }
[DataMember]
public Instant LastModified { get; set; }
[DataMember]
public long Version { get; set; } = EtagVersion.Empty;
public bool IsDeleted { get; set; }
public abstract bool ApplyEvent(IEvent @event);
public long Version { get; set; }
protected DomainObjectState()
{
Version = EtagVersion.Empty;
}
public virtual bool ApplyEvent(IEvent @event, EnvelopeHeaders headers)
{
return ApplyEvent(@event);
}
public virtual bool ApplyEvent(IEvent @event)
{
return false;
}
public T Apply(Envelope<IEvent> @event)
{
@ -49,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities
var clone = (DomainObjectState<T>)MemberwiseClone();
if (!clone.ApplyEvent(@event.Payload))
if (!clone.ApplyEvent(@event.Payload, @event.Headers))
{
return (this as T)!;
}

3
backend/src/Squidex.Domain.Apps.Entities/FodyWeavers.xml

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Equals />
</Weavers>

26
backend/src/Squidex.Domain.Apps.Entities/FodyWeavers.xsd

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Equals" minOccurs="0" maxOccurs="1" type="xs:anyType" />
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

8
backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs

@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Entities.History
public string Channel { get; set; }
public string Message { get; set; }
public string EventType { get; set; }
public Dictionary<string, string> Parameters { get; set; } = new Dictionary<string, string>();
@ -34,14 +34,14 @@ namespace Squidex.Domain.Apps.Entities.History
{
}
public HistoryEvent(string channel, string message)
public HistoryEvent(string channel, string eventType)
{
Guard.NotNullOrEmpty(channel);
Guard.NotNullOrEmpty(message);
Guard.NotNullOrEmpty(eventType);
Channel = channel;
Message = message;
EventType = eventType;
}
public HistoryEvent Param(string key, object? value)

7
backend/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs

@ -42,6 +42,11 @@ namespace Squidex.Domain.Apps.Entities.History
get { return item.Channel; }
}
public string EventType
{
get { return item.EventType; }
}
public string? Message
{
get { return message.Value; }
@ -53,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.History
message = new Lazy<string?>(() =>
{
if (texts.TryGetValue(item.Message, out var result))
if (texts.TryGetValue(item.EventType, out var result))
{
foreach (var (key, value) in item.Parameters)
{

8
backend/src/Squidex.Domain.Apps.Entities/Q.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities
{
@ -22,6 +23,13 @@ namespace Squidex.Domain.Apps.Entities
public string? JsonQuery { get; private set; }
public ClrQuery? Query { get; private set; }
public Q WithQuery(ClrQuery? query)
{
return Clone(c => c.Query = query);
}
public Q WithODataQuery(string? odataQuery)
{
return Clone(c => c.ODataQuery = odataQuery);

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save