mirror of https://github.com/Squidex/squidex.git
67 changed files with 2449 additions and 383 deletions
@ -1,147 +0,0 @@ |
|||||
// ==========================================================================
|
|
||||
// Squidex Headless CMS
|
|
||||
// ==========================================================================
|
|
||||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|
||||
// All rights reserved. Licensed under the MIT license.
|
|
||||
// ==========================================================================
|
|
||||
|
|
||||
using System; |
|
||||
using System.Collections.Generic; |
|
||||
using System.Linq; |
|
||||
using System.Threading; |
|
||||
using System.Threading.Tasks; |
|
||||
using MongoDB.Driver; |
|
||||
using NodaTime; |
|
||||
using Squidex.Domain.Apps.Core.ConvertContent; |
|
||||
using Squidex.Domain.Apps.Entities.Apps; |
|
||||
using Squidex.Domain.Apps.Entities.Contents; |
|
||||
using Squidex.Domain.Apps.Entities.Contents.State; |
|
||||
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; |
|
||||
using Squidex.Domain.Apps.Entities.Schemas; |
|
||||
using Squidex.Infrastructure; |
|
||||
using Squidex.Infrastructure.Json; |
|
||||
using Squidex.Infrastructure.MongoDb; |
|
||||
using Squidex.Infrastructure.Queries; |
|
||||
using Squidex.Infrastructure.Reflection; |
|
||||
using Squidex.Infrastructure.States; |
|
||||
|
|
||||
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents |
|
||||
{ |
|
||||
internal sealed class MongoContentDraftCollection : MongoContentCollection |
|
||||
{ |
|
||||
public MongoContentDraftCollection(IMongoDatabase database, IJsonSerializer serializer) |
|
||||
: base(database, serializer, "State_Content_Draft") |
|
||||
{ |
|
||||
} |
|
||||
|
|
||||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) |
|
||||
{ |
|
||||
await collection.Indexes.CreateManyAsync( |
|
||||
new[] |
|
||||
{ |
|
||||
new CreateIndexModel<MongoContentEntity>( |
|
||||
Index |
|
||||
.Ascending(x => x.IndexedSchemaId) |
|
||||
.Ascending(x => x.Id) |
|
||||
.Ascending(x => x.IsDeleted)), |
|
||||
new CreateIndexModel<MongoContentEntity>( |
|
||||
Index |
|
||||
.Text(x => x.DataText) |
|
||||
.Ascending(x => x.IndexedSchemaId) |
|
||||
.Ascending(x => x.IsDeleted) |
|
||||
.Ascending(x => x.Status)) |
|
||||
}, ct); |
|
||||
|
|
||||
await base.SetupCollectionAsync(collection, ct); |
|
||||
} |
|
||||
|
|
||||
public async Task<IReadOnlyList<Guid>> QueryIdsAsync(Guid appId, ISchemaEntity schema, FilterNode filterNode) |
|
||||
{ |
|
||||
var filter = filterNode.AdjustToModel(schema.SchemaDef, true).ToFilter(schema.Id); |
|
||||
|
|
||||
var contentEntities = |
|
||||
await Collection.Find(filter).Only(x => x.Id) |
|
||||
.ToListAsync(); |
|
||||
|
|
||||
return contentEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); |
|
||||
} |
|
||||
|
|
||||
public async Task<IReadOnlyList<Guid>> QueryIdsAsync(Guid appId) |
|
||||
{ |
|
||||
var contentEntities = |
|
||||
await Collection.Find(x => x.IndexedAppId == appId).Only(x => x.Id) |
|
||||
.ToListAsync(); |
|
||||
|
|
||||
return contentEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); |
|
||||
} |
|
||||
|
|
||||
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback) |
|
||||
{ |
|
||||
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true) |
|
||||
.Not(x => x.DataByIds) |
|
||||
.Not(x => x.DataDraftByIds) |
|
||||
.Not(x => x.DataText) |
|
||||
.ForEachAsync(c => |
|
||||
{ |
|
||||
callback(c); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) |
|
||||
{ |
|
||||
var contentEntity = |
|
||||
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id && x.IsDeleted != true).Not(x => x.DataText) |
|
||||
.FirstOrDefaultAsync(); |
|
||||
|
|
||||
contentEntity?.ParseData(schema.SchemaDef, Serializer); |
|
||||
|
|
||||
return contentEntity; |
|
||||
} |
|
||||
|
|
||||
public async Task<(ContentState Value, long Version)> ReadAsync(Guid key, Func<Guid, Guid, Task<ISchemaEntity>> getSchema) |
|
||||
{ |
|
||||
var contentEntity = |
|
||||
await Collection.Find(x => x.Id == key).Not(x => x.DataText) |
|
||||
.FirstOrDefaultAsync(); |
|
||||
|
|
||||
if (contentEntity != null) |
|
||||
{ |
|
||||
var schema = await getSchema(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId); |
|
||||
|
|
||||
contentEntity.ParseData(schema.SchemaDef, Serializer); |
|
||||
|
|
||||
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); |
|
||||
} |
|
||||
|
|
||||
return (null, EtagVersion.NotFound); |
|
||||
} |
|
||||
|
|
||||
public async Task UpsertAsync(MongoContentEntity content, long oldVersion) |
|
||||
{ |
|
||||
try |
|
||||
{ |
|
||||
content.DataText = content.DataDraftByIds.ToFullText(); |
|
||||
|
|
||||
await Collection.ReplaceOneAsync(x => x.Id == content.Id && x.Version == oldVersion, content, Upsert); |
|
||||
} |
|
||||
catch (MongoWriteException ex) |
|
||||
{ |
|
||||
if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) |
|
||||
{ |
|
||||
var existingVersion = |
|
||||
await Collection.Find(x => x.Id == content.Id).Only(x => x.Id, x => x.Version) |
|
||||
.FirstOrDefaultAsync(); |
|
||||
|
|
||||
if (existingVersion != null) |
|
||||
{ |
|
||||
throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex); |
|
||||
} |
|
||||
} |
|
||||
else |
|
||||
{ |
|
||||
throw; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,61 +0,0 @@ |
|||||
// ==========================================================================
|
|
||||
// Squidex Headless CMS
|
|
||||
// ==========================================================================
|
|
||||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|
||||
// All rights reserved. Licensed under the MIT license.
|
|
||||
// ==========================================================================
|
|
||||
|
|
||||
using System; |
|
||||
using System.Threading; |
|
||||
using System.Threading.Tasks; |
|
||||
using MongoDB.Driver; |
|
||||
using Squidex.Domain.Apps.Core.ConvertContent; |
|
||||
using Squidex.Domain.Apps.Entities.Apps; |
|
||||
using Squidex.Domain.Apps.Entities.Contents; |
|
||||
using Squidex.Domain.Apps.Entities.Schemas; |
|
||||
using Squidex.Infrastructure.Json; |
|
||||
using Squidex.Infrastructure.MongoDb; |
|
||||
|
|
||||
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents |
|
||||
{ |
|
||||
internal sealed class MongoContentPublishedCollection : MongoContentCollection |
|
||||
{ |
|
||||
public MongoContentPublishedCollection(IMongoDatabase database, IJsonSerializer serializer) |
|
||||
: base(database, serializer, "State_Content_Published") |
|
||||
{ |
|
||||
} |
|
||||
|
|
||||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) |
|
||||
{ |
|
||||
await collection.Indexes.CreateManyAsync( |
|
||||
new[] |
|
||||
{ |
|
||||
new CreateIndexModel<MongoContentEntity>(Index.Text(x => x.DataText).Ascending(x => x.IndexedSchemaId)), |
|
||||
new CreateIndexModel<MongoContentEntity>(Index.Ascending(x => x.IndexedSchemaId).Ascending(x => x.Id)) |
|
||||
}, ct); |
|
||||
|
|
||||
await base.SetupCollectionAsync(collection, ct); |
|
||||
} |
|
||||
|
|
||||
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) |
|
||||
{ |
|
||||
var contentEntity = |
|
||||
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id).Not(x => x.DataText) |
|
||||
.FirstOrDefaultAsync(); |
|
||||
|
|
||||
contentEntity?.ParseData(schema.SchemaDef, Serializer); |
|
||||
|
|
||||
return contentEntity; |
|
||||
} |
|
||||
|
|
||||
public Task UpsertAsync(MongoContentEntity content) |
|
||||
{ |
|
||||
content.DataText = content.DataByIds.ToFullText(); |
|
||||
content.DataDraftByIds = null; |
|
||||
content.ScheduleJob = null; |
|
||||
content.ScheduledAt = null; |
|
||||
|
|
||||
return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true }); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,106 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Domain.Apps.Entities.Schemas; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public sealed class GrainTextIndexer : ITextIndexer |
||||
|
{ |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
private readonly ISemanticLog log; |
||||
|
|
||||
|
public GrainTextIndexer(IGrainFactory grainFactory, ISemanticLog log) |
||||
|
{ |
||||
|
Guard.NotNull(grainFactory, nameof(grainFactory)); |
||||
|
Guard.NotNull(log, nameof(log)); |
||||
|
|
||||
|
this.grainFactory = grainFactory; |
||||
|
|
||||
|
this.log = log; |
||||
|
} |
||||
|
|
||||
|
public async Task DeleteAsync(Guid schemaId, Guid id) |
||||
|
{ |
||||
|
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
||||
|
|
||||
|
using (Profiler.TraceMethod<GrainTextIndexer>()) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await index.DeleteAsync(id); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "DeleteTextEntry") |
||||
|
.WriteProperty("status", "Failed")); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task IndexAsync(Guid schemaId, Guid id, NamedContentData data, NamedContentData dataDraft) |
||||
|
{ |
||||
|
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
||||
|
|
||||
|
using (Profiler.TraceMethod<GrainTextIndexer>()) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (data != null) |
||||
|
{ |
||||
|
await index.IndexAsync(id, new IndexData { Data = data }); |
||||
|
} |
||||
|
|
||||
|
if (dataDraft != null) |
||||
|
{ |
||||
|
await index.IndexAsync(id, new IndexData { Data = dataDraft, IsDraft = true }); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "UpdateTextEntry") |
||||
|
.WriteProperty("status", "Failed")); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<List<Guid>> SearchAsync(string queryText, IAppEntity app, Guid schemaId, bool useDraft = false) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(queryText)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
||||
|
|
||||
|
using (Profiler.TraceMethod<GrainTextIndexer>()) |
||||
|
{ |
||||
|
var context = CreateContext(app, useDraft); |
||||
|
|
||||
|
return await index.SearchAsync(queryText, context); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static SearchContext CreateContext(IAppEntity app, bool useDraft) |
||||
|
{ |
||||
|
var languages = new HashSet<string>(app.LanguagesConfig.Select(x => x.Key)); |
||||
|
|
||||
|
return new SearchContext { Languages = languages, IsDraft = useDraft }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public interface ITextIndexer |
||||
|
{ |
||||
|
Task DeleteAsync(Guid schemaId, Guid id); |
||||
|
|
||||
|
Task IndexAsync(Guid schemaId, Guid id, NamedContentData data, NamedContentData dataDraft); |
||||
|
|
||||
|
Task<List<Guid>> SearchAsync(string queryText, IAppEntity app, Guid schemaId, bool useDraft = false); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public interface ITextIndexerGrain : IGrainWithGuidKey |
||||
|
{ |
||||
|
Task DeleteAsync(Guid id); |
||||
|
|
||||
|
Task IndexAsync(Guid id, J<IndexData> data); |
||||
|
|
||||
|
Task<List<Guid>> SearchAsync(string queryText, SearchContext context); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public sealed class IndexData |
||||
|
{ |
||||
|
public NamedContentData Data { get; set; } |
||||
|
|
||||
|
public bool IsDraft { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,65 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using Lucene.Net.Analysis; |
||||
|
using Lucene.Net.Analysis.Standard; |
||||
|
using Lucene.Net.Util; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public sealed class MultiLanguageAnalyzer : AnalyzerWrapper |
||||
|
{ |
||||
|
private readonly StandardAnalyzer fallbackAnalyzer; |
||||
|
private readonly Dictionary<string, Analyzer> analyzers = new Dictionary<string, Analyzer>(StringComparer.OrdinalIgnoreCase); |
||||
|
|
||||
|
public MultiLanguageAnalyzer(LuceneVersion version) |
||||
|
: base(PER_FIELD_REUSE_STRATEGY) |
||||
|
{ |
||||
|
fallbackAnalyzer = new StandardAnalyzer(version); |
||||
|
|
||||
|
foreach (var type in typeof(StandardAnalyzer).Assembly.GetTypes()) |
||||
|
{ |
||||
|
if (typeof(Analyzer).IsAssignableFrom(type)) |
||||
|
{ |
||||
|
var language = type.Namespace.Split('.').Last(); |
||||
|
|
||||
|
if (language.Length == 2) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var analyzer = Activator.CreateInstance(type, version); |
||||
|
|
||||
|
analyzers[language] = (Analyzer)analyzer; |
||||
|
} |
||||
|
catch (MissingMethodException) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected override Analyzer GetWrappedAnalyzer(string fieldName) |
||||
|
{ |
||||
|
if (fieldName.Length > 0) |
||||
|
{ |
||||
|
var analyzer = analyzers.GetOrDefault(fieldName.Substring(0, 2)) ?? fallbackAnalyzer; |
||||
|
|
||||
|
return analyzer; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
return fallbackAnalyzer; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,91 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.IO.Compression; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public static class PersistenceHelper |
||||
|
{ |
||||
|
private const string ArchiveFile = "Archive.zip"; |
||||
|
private const string LockFile = "write.lock"; |
||||
|
|
||||
|
public static async Task UploadDirectoryAsync(this IAssetStore assetStore, DirectoryInfo directory) |
||||
|
{ |
||||
|
using (var fileStream = new FileStream( |
||||
|
Path.Combine(directory.FullName, ArchiveFile), |
||||
|
FileMode.Create, |
||||
|
FileAccess.ReadWrite, |
||||
|
FileShare.None, |
||||
|
4096, |
||||
|
FileOptions.DeleteOnClose)) |
||||
|
{ |
||||
|
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, true)) |
||||
|
{ |
||||
|
foreach (var file in directory.GetFiles()) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!file.Name.Equals(ArchiveFile, StringComparison.OrdinalIgnoreCase) && |
||||
|
!file.Name.Equals(LockFile, StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
zipArchive.CreateEntryFromFile(file.FullName, file.Name); |
||||
|
} |
||||
|
} |
||||
|
catch (IOException) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fileStream.Position = 0; |
||||
|
|
||||
|
await assetStore.UploadAsync(directory.Name, 0, string.Empty, fileStream, true); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static async Task DownloadAsync(this IAssetStore assetStore, DirectoryInfo directory) |
||||
|
{ |
||||
|
if (directory.Exists) |
||||
|
{ |
||||
|
directory.Delete(true); |
||||
|
} |
||||
|
|
||||
|
directory.Create(); |
||||
|
|
||||
|
using (var fileStream = new FileStream( |
||||
|
Path.Combine(directory.FullName, ArchiveFile), |
||||
|
FileMode.Create, |
||||
|
FileAccess.ReadWrite, |
||||
|
FileShare.None, |
||||
|
4096, |
||||
|
FileOptions.DeleteOnClose)) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await assetStore.DownloadAsync(directory.Name, 0, string.Empty, fileStream); |
||||
|
|
||||
|
fileStream.Position = 0; |
||||
|
|
||||
|
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, true)) |
||||
|
{ |
||||
|
zipArchive.ExtractToDirectory(directory.FullName); |
||||
|
} |
||||
|
} |
||||
|
catch (AssetNotFoundException) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public sealed class SearchContext |
||||
|
{ |
||||
|
public bool IsDraft { get; set; } |
||||
|
|
||||
|
public HashSet<string> Languages { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,272 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Text; |
||||
|
using System.Threading.Tasks; |
||||
|
using Lucene.Net.Analysis; |
||||
|
using Lucene.Net.Documents; |
||||
|
using Lucene.Net.Index; |
||||
|
using Lucene.Net.Queries; |
||||
|
using Lucene.Net.QueryParsers.Classic; |
||||
|
using Lucene.Net.Search; |
||||
|
using Lucene.Net.Store; |
||||
|
using Lucene.Net.Util; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public sealed class TextIndexerGrain : GrainOfGuid, ITextIndexerGrain |
||||
|
{ |
||||
|
private const LuceneVersion Version = LuceneVersion.LUCENE_48; |
||||
|
private const int MaxResults = 2000; |
||||
|
private const int MaxUpdates = 100; |
||||
|
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(30); |
||||
|
private static readonly Analyzer Analyzer = new MultiLanguageAnalyzer(Version); |
||||
|
private readonly IAssetStore assetStore; |
||||
|
private IDisposable timer; |
||||
|
private DirectoryInfo directory; |
||||
|
private IndexWriter indexWriter; |
||||
|
private IndexReader indexReader; |
||||
|
private QueryParser queryParser; |
||||
|
private HashSet<string> currentLanguages; |
||||
|
private long updates; |
||||
|
|
||||
|
public TextIndexerGrain(IAssetStore assetStore) |
||||
|
{ |
||||
|
Guard.NotNull(assetStore, nameof(assetStore)); |
||||
|
|
||||
|
this.assetStore = assetStore; |
||||
|
} |
||||
|
|
||||
|
public override async Task OnDeactivateAsync() |
||||
|
{ |
||||
|
await DeactivateAsync(true); |
||||
|
} |
||||
|
|
||||
|
protected override async Task OnActivateAsync(Guid key) |
||||
|
{ |
||||
|
directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"Index_{key}")); |
||||
|
|
||||
|
await assetStore.DownloadAsync(directory); |
||||
|
|
||||
|
indexWriter = new IndexWriter(FSDirectory.Open(directory), new IndexWriterConfig(Version, Analyzer)); |
||||
|
indexReader = indexWriter.GetReader(true); |
||||
|
} |
||||
|
|
||||
|
public Task DeleteAsync(Guid id) |
||||
|
{ |
||||
|
indexWriter.DeleteDocuments(new Term("id", id.ToString())); |
||||
|
|
||||
|
return TryFlushAsync(); |
||||
|
} |
||||
|
|
||||
|
public Task IndexAsync(Guid id, J<IndexData> data) |
||||
|
{ |
||||
|
var docId = id.ToString(); |
||||
|
var docDraft = data.Value.IsDraft.ToString(); |
||||
|
var docKey = $"{docId}_{docDraft}"; |
||||
|
|
||||
|
var query = new BooleanQuery(); |
||||
|
|
||||
|
indexWriter.DeleteDocuments(new Term("key", docKey)); |
||||
|
|
||||
|
var languages = new Dictionary<string, StringBuilder>(); |
||||
|
|
||||
|
void AppendText(string language, string text) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(text)) |
||||
|
{ |
||||
|
var sb = languages.GetOrAddNew(language); |
||||
|
|
||||
|
if (sb.Length > 0) |
||||
|
{ |
||||
|
sb.Append(" "); |
||||
|
} |
||||
|
|
||||
|
sb.Append(text); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
foreach (var field in data.Value.Data) |
||||
|
{ |
||||
|
foreach (var fieldValue in field.Value) |
||||
|
{ |
||||
|
var appendText = new Action<string>(text => AppendText(fieldValue.Key, text)); |
||||
|
|
||||
|
AppendJsonText(fieldValue.Value, appendText); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (languages.Count > 0) |
||||
|
{ |
||||
|
var document = new Document(); |
||||
|
|
||||
|
document.AddStringField("id", docId, Field.Store.YES); |
||||
|
document.AddStringField("key", docKey, Field.Store.YES); |
||||
|
document.AddStringField("draft", docDraft, Field.Store.YES); |
||||
|
|
||||
|
foreach (var field in languages) |
||||
|
{ |
||||
|
var fieldName = BuildFieldName(field.Key); |
||||
|
|
||||
|
document.AddTextField(fieldName, field.Value.ToString(), Field.Store.NO); |
||||
|
} |
||||
|
|
||||
|
indexWriter.AddDocument(document); |
||||
|
} |
||||
|
|
||||
|
return TryFlushAsync(); |
||||
|
} |
||||
|
|
||||
|
private static void AppendJsonText(IJsonValue value, Action<string> appendText) |
||||
|
{ |
||||
|
if (value.Type == JsonValueType.String) |
||||
|
{ |
||||
|
appendText(value.ToString()); |
||||
|
} |
||||
|
else if (value is JsonArray array) |
||||
|
{ |
||||
|
foreach (var item in array) |
||||
|
{ |
||||
|
AppendJsonText(item, appendText); |
||||
|
} |
||||
|
} |
||||
|
else if (value is JsonObject obj) |
||||
|
{ |
||||
|
foreach (var item in obj.Values) |
||||
|
{ |
||||
|
AppendJsonText(item, appendText); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task<List<Guid>> SearchAsync(string queryText, SearchContext context) |
||||
|
{ |
||||
|
var result = new HashSet<Guid>(); |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(queryText)) |
||||
|
{ |
||||
|
var query = BuildQuery(queryText, context); |
||||
|
|
||||
|
if (indexReader != null) |
||||
|
{ |
||||
|
var filter = new TermsFilter(new Term("draft", context.IsDraft.ToString())); |
||||
|
|
||||
|
var hits = new IndexSearcher(indexReader).Search(query, filter, MaxResults).ScoreDocs; |
||||
|
|
||||
|
foreach (var hit in hits) |
||||
|
{ |
||||
|
var document = indexReader.Document(hit.Doc); |
||||
|
|
||||
|
var idField = document.GetField("id")?.GetStringValue(); |
||||
|
|
||||
|
if (idField != null && Guid.TryParse(idField, out var guid)) |
||||
|
{ |
||||
|
result.Add(guid); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Task.FromResult(result.ToList()); |
||||
|
} |
||||
|
|
||||
|
private Query BuildQuery(string query, SearchContext context) |
||||
|
{ |
||||
|
if (queryParser == null || !currentLanguages.SetEquals(context.Languages)) |
||||
|
{ |
||||
|
var fields = |
||||
|
context.Languages.Select(BuildFieldName) |
||||
|
.Union(Enumerable.Repeat(BuildFieldName("iv"), 1)).ToArray(); |
||||
|
|
||||
|
queryParser = new MultiFieldQueryParser(Version, fields, Analyzer); |
||||
|
|
||||
|
currentLanguages = context.Languages; |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
return queryParser.Parse(query); |
||||
|
} |
||||
|
catch (ParseException ex) |
||||
|
{ |
||||
|
throw new ValidationException(ex.Message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task TryFlushAsync() |
||||
|
{ |
||||
|
updates++; |
||||
|
|
||||
|
if (updates >= MaxUpdates) |
||||
|
{ |
||||
|
await FlushAsync(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
timer?.Dispose(); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
timer = RegisterTimer(_ => FlushAsync(), null, CommitDelay, CommitDelay); |
||||
|
} |
||||
|
catch (InvalidOperationException) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task FlushAsync() |
||||
|
{ |
||||
|
if (updates > 0 && indexWriter != null) |
||||
|
{ |
||||
|
indexWriter.Flush(true, true); |
||||
|
indexWriter.Commit(); |
||||
|
|
||||
|
indexReader?.Dispose(); |
||||
|
indexReader = indexWriter.GetReader(true); |
||||
|
|
||||
|
await assetStore.UploadDirectoryAsync(directory); |
||||
|
|
||||
|
updates = 0; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
timer?.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task DeactivateAsync(bool deleteFolder = false) |
||||
|
{ |
||||
|
await TryFlushAsync(); |
||||
|
|
||||
|
indexWriter?.Dispose(); |
||||
|
indexWriter = null; |
||||
|
|
||||
|
indexReader?.Dispose(); |
||||
|
indexReader = null; |
||||
|
|
||||
|
if (deleteFolder && directory.Exists) |
||||
|
{ |
||||
|
directory.Delete(true); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static string BuildFieldName(string language) |
||||
|
{ |
||||
|
return $"{language}_field"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Azure.Documents.Client; |
||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.Diagnostics |
||||
|
{ |
||||
|
public sealed class CosmosDbHealthCheck : IHealthCheck |
||||
|
{ |
||||
|
private readonly DocumentClient documentClient; |
||||
|
|
||||
|
public CosmosDbHealthCheck(Uri uri, string masterKey) |
||||
|
{ |
||||
|
documentClient = new DocumentClient(uri, masterKey); |
||||
|
} |
||||
|
|
||||
|
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
await documentClient.ReadDatabaseFeedAsync(); |
||||
|
|
||||
|
return HealthCheckResult.Healthy("Application must query data from CosmosDB."); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal static class Constants |
||||
|
{ |
||||
|
public const string Collection = "Events"; |
||||
|
|
||||
|
public const string LeaseCollection = "Leases"; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Newtonsoft.Json; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal sealed class CosmosDbEvent |
||||
|
{ |
||||
|
[JsonProperty("type")] |
||||
|
public string Type { get; set; } |
||||
|
|
||||
|
[JsonProperty("payload")] |
||||
|
public string Payload { get; set; } |
||||
|
|
||||
|
[JsonProperty("header")] |
||||
|
public EnvelopeHeaders Headers { get; set; } |
||||
|
|
||||
|
public static CosmosDbEvent FromEventData(EventData data) |
||||
|
{ |
||||
|
return new CosmosDbEvent { Type = data.Type, Headers = data.Headers, Payload = data.Payload }; |
||||
|
} |
||||
|
|
||||
|
public EventData ToEventData() |
||||
|
{ |
||||
|
return new EventData(Type, Headers, Payload); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Newtonsoft.Json; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal sealed class CosmosDbEventCommit |
||||
|
{ |
||||
|
[JsonProperty("id")] |
||||
|
public Guid Id { get; set; } |
||||
|
|
||||
|
[JsonProperty("events")] |
||||
|
public CosmosDbEvent[] Events { get; set; } |
||||
|
|
||||
|
[JsonProperty("eventStreamOffset")] |
||||
|
public long EventStreamOffset { get; set; } |
||||
|
|
||||
|
[JsonProperty("eventsCount")] |
||||
|
public long EventsCount { get; set; } |
||||
|
|
||||
|
[JsonProperty("eventStream")] |
||||
|
public string EventStream { get; set; } |
||||
|
|
||||
|
[JsonProperty("timestamp")] |
||||
|
public long Timestamp { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,124 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.ObjectModel; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Azure.Documents; |
||||
|
using Microsoft.Azure.Documents.Client; |
||||
|
using Newtonsoft.Json; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
public sealed partial class CosmosDbEventStore : DisposableObjectBase, IEventStore, IInitializable |
||||
|
{ |
||||
|
private readonly DocumentClient documentClient; |
||||
|
private readonly Uri collectionUri; |
||||
|
private readonly Uri databaseUri; |
||||
|
private readonly string masterKey; |
||||
|
private readonly string databaseId; |
||||
|
private readonly JsonSerializerSettings serializerSettings; |
||||
|
|
||||
|
public JsonSerializerSettings SerializerSettings |
||||
|
{ |
||||
|
get { return serializerSettings; } |
||||
|
} |
||||
|
|
||||
|
public string DatabaseId |
||||
|
{ |
||||
|
get { return databaseId; } |
||||
|
} |
||||
|
|
||||
|
public string MasterKey |
||||
|
{ |
||||
|
get { return masterKey; } |
||||
|
} |
||||
|
|
||||
|
public Uri ServiceUri |
||||
|
{ |
||||
|
get { return documentClient.ServiceEndpoint; } |
||||
|
} |
||||
|
|
||||
|
public CosmosDbEventStore(DocumentClient documentClient, string masterKey, string database, JsonSerializerSettings serializerSettings) |
||||
|
{ |
||||
|
Guard.NotNull(documentClient, nameof(documentClient)); |
||||
|
Guard.NotNull(serializerSettings, nameof(serializerSettings)); |
||||
|
Guard.NotNullOrEmpty(masterKey, nameof(masterKey)); |
||||
|
Guard.NotNullOrEmpty(database, nameof(database)); |
||||
|
|
||||
|
this.documentClient = documentClient; |
||||
|
|
||||
|
databaseUri = UriFactory.CreateDatabaseUri(database); |
||||
|
databaseId = database; |
||||
|
|
||||
|
collectionUri = UriFactory.CreateDocumentCollectionUri(database, Constants.Collection); |
||||
|
|
||||
|
this.masterKey = masterKey; |
||||
|
|
||||
|
this.serializerSettings = serializerSettings; |
||||
|
} |
||||
|
|
||||
|
protected override void DisposeObject(bool disposing) |
||||
|
{ |
||||
|
if (disposing) |
||||
|
{ |
||||
|
documentClient.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task InitializeAsync(CancellationToken ct = default) |
||||
|
{ |
||||
|
await documentClient.CreateDatabaseIfNotExistsAsync(new Database { Id = databaseId }); |
||||
|
|
||||
|
await documentClient.CreateDocumentCollectionIfNotExistsAsync(databaseUri, |
||||
|
new DocumentCollection |
||||
|
{ |
||||
|
Id = Constants.LeaseCollection, |
||||
|
}); |
||||
|
|
||||
|
await documentClient.CreateDocumentCollectionIfNotExistsAsync(databaseUri, |
||||
|
new DocumentCollection |
||||
|
{ |
||||
|
IndexingPolicy = new IndexingPolicy |
||||
|
{ |
||||
|
IncludedPaths = new Collection<IncludedPath> |
||||
|
{ |
||||
|
new IncludedPath |
||||
|
{ |
||||
|
Path = "/*", |
||||
|
Indexes = new Collection<Index> |
||||
|
{ |
||||
|
Index.Range(DataType.Number), |
||||
|
Index.Range(DataType.String), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
UniqueKeyPolicy = new UniqueKeyPolicy |
||||
|
{ |
||||
|
UniqueKeys = new Collection<UniqueKey> |
||||
|
{ |
||||
|
new UniqueKey |
||||
|
{ |
||||
|
Paths = new Collection<string> |
||||
|
{ |
||||
|
$"/eventStream", |
||||
|
$"/eventStreamOffset" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
Id = Constants.Collection, |
||||
|
}, |
||||
|
new RequestOptions |
||||
|
{ |
||||
|
PartitionKey = new PartitionKey($"/eventStream") |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,142 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Azure.Documents; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
public delegate bool EventPredicate(EventData data); |
||||
|
|
||||
|
public partial class CosmosDbEventStore : IEventStore, IInitializable |
||||
|
{ |
||||
|
public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter = null, string position = null) |
||||
|
{ |
||||
|
Guard.NotNull(subscriber, nameof(subscriber)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
return new CosmosDbSubscription(this, subscriber, streamFilter, position); |
||||
|
} |
||||
|
|
||||
|
public Task CreateIndexAsync(string property) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(property, nameof(property)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
using (Profiler.TraceMethod<CosmosDbEventStore>()) |
||||
|
{ |
||||
|
var query = FilterBuilder.ByStreamName(streamName, streamPosition - MaxCommitSize); |
||||
|
|
||||
|
var result = new List<StoredEvent>(); |
||||
|
|
||||
|
await documentClient.QueryAsync(collectionUri, query, commit => |
||||
|
{ |
||||
|
var eventStreamOffset = (int)commit.EventStreamOffset; |
||||
|
|
||||
|
var commitTimestamp = commit.Timestamp; |
||||
|
var commitOffset = 0; |
||||
|
|
||||
|
foreach (var @event in commit.Events) |
||||
|
{ |
||||
|
eventStreamOffset++; |
||||
|
|
||||
|
if (eventStreamOffset >= streamPosition) |
||||
|
{ |
||||
|
var eventData = @event.ToEventData(); |
||||
|
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); |
||||
|
|
||||
|
result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
}); |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task QueryAsync(Func<StoredEvent, Task> callback, string property, object value, string position = null, CancellationToken ct = default) |
||||
|
{ |
||||
|
Guard.NotNull(callback, nameof(callback)); |
||||
|
Guard.NotNullOrEmpty(property, nameof(property)); |
||||
|
Guard.NotNull(value, nameof(value)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
StreamPosition lastPosition = position; |
||||
|
|
||||
|
var filterDefinition = FilterBuilder.CreateByProperty(property, value, lastPosition); |
||||
|
var filterExpression = FilterBuilder.CreateExpression(property, value); |
||||
|
|
||||
|
return QueryAsync(callback, lastPosition, filterDefinition, filterExpression, ct); |
||||
|
} |
||||
|
|
||||
|
public Task QueryAsync(Func<StoredEvent, Task> callback, string streamFilter = null, string position = null, CancellationToken ct = default) |
||||
|
{ |
||||
|
Guard.NotNull(callback, nameof(callback)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
StreamPosition lastPosition = position; |
||||
|
|
||||
|
var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition); |
||||
|
var filterExpression = FilterBuilder.CreateExpression(null, null); |
||||
|
|
||||
|
return QueryAsync(callback, lastPosition, filterDefinition, filterExpression, ct); |
||||
|
} |
||||
|
|
||||
|
private async Task QueryAsync(Func<StoredEvent, Task> callback, StreamPosition lastPosition, SqlQuerySpec query, EventPredicate filterExpression, CancellationToken ct = default) |
||||
|
{ |
||||
|
using (Profiler.TraceMethod<CosmosDbEventStore>()) |
||||
|
{ |
||||
|
await documentClient.QueryAsync(collectionUri, query, async commit => |
||||
|
{ |
||||
|
var eventStreamOffset = (int)commit.EventStreamOffset; |
||||
|
|
||||
|
var commitTimestamp = commit.Timestamp; |
||||
|
var commitOffset = 0; |
||||
|
|
||||
|
foreach (var @event in commit.Events) |
||||
|
{ |
||||
|
eventStreamOffset++; |
||||
|
|
||||
|
if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) |
||||
|
{ |
||||
|
var eventData = @event.ToEventData(); |
||||
|
|
||||
|
if (filterExpression(eventData)) |
||||
|
{ |
||||
|
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); |
||||
|
|
||||
|
await callback(new StoredEvent(commit.EventStream, eventToken, eventStreamOffset, eventData)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
commitOffset++; |
||||
|
} |
||||
|
}, ct); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,149 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Net; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Azure.Documents; |
||||
|
using Microsoft.Azure.Documents.Client; |
||||
|
using NodaTime; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
public partial class CosmosDbEventStore |
||||
|
{ |
||||
|
private const int MaxWriteAttempts = 20; |
||||
|
private const int MaxCommitSize = 10; |
||||
|
|
||||
|
public Task DeleteStreamAsync(string streamName) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
var query = FilterBuilder.AllIds(streamName); |
||||
|
|
||||
|
return documentClient.QueryAsync(collectionUri, query, commit => |
||||
|
{ |
||||
|
var documentUri = UriFactory.CreateDocumentUri(databaseId, Constants.Collection, commit.Id.ToString()); |
||||
|
|
||||
|
return documentClient.DeleteDocumentAsync(documentUri); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events) |
||||
|
{ |
||||
|
return AppendAsync(commitId, streamName, EtagVersion.Any, events); |
||||
|
} |
||||
|
|
||||
|
public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events) |
||||
|
{ |
||||
|
Guard.NotEmpty(commitId, nameof(commitId)); |
||||
|
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
||||
|
Guard.NotNull(events, nameof(events)); |
||||
|
Guard.LessThan(events.Count, MaxCommitSize, "events.Count"); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
using (Profiler.TraceMethod<CosmosDbEventStore>()) |
||||
|
{ |
||||
|
if (events.Count == 0) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var currentVersion = await GetEventStreamOffsetAsync(streamName); |
||||
|
|
||||
|
if (expectedVersion != EtagVersion.Any && expectedVersion != currentVersion) |
||||
|
{ |
||||
|
throw new WrongEventVersionException(currentVersion, expectedVersion); |
||||
|
} |
||||
|
|
||||
|
var commit = BuildCommit(commitId, streamName, expectedVersion >= -1 ? expectedVersion : currentVersion, events); |
||||
|
|
||||
|
for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await documentClient.CreateDocumentAsync(collectionUri, commit); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
catch (DocumentClientException ex) |
||||
|
{ |
||||
|
if (ex.StatusCode == HttpStatusCode.Conflict) |
||||
|
{ |
||||
|
currentVersion = await GetEventStreamOffsetAsync(streamName); |
||||
|
|
||||
|
if (expectedVersion != EtagVersion.Any) |
||||
|
{ |
||||
|
throw new WrongEventVersionException(currentVersion, expectedVersion); |
||||
|
} |
||||
|
|
||||
|
if (attempt < MaxWriteAttempts) |
||||
|
{ |
||||
|
expectedVersion = currentVersion; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
throw new TimeoutException("Could not acquire a free slot for the commit within the provided time."); |
||||
|
} |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
throw; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<long> GetEventStreamOffsetAsync(string streamName) |
||||
|
{ |
||||
|
var query = |
||||
|
documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, |
||||
|
FilterBuilder.LastPosition(streamName)); |
||||
|
|
||||
|
var document = await query.FirstOrDefaultAsync(); |
||||
|
|
||||
|
if (document != null) |
||||
|
{ |
||||
|
return document.EventStreamOffset + document.EventsCount; |
||||
|
} |
||||
|
|
||||
|
return EtagVersion.Empty; |
||||
|
} |
||||
|
|
||||
|
private static CosmosDbEventCommit BuildCommit(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events) |
||||
|
{ |
||||
|
var commitEvents = new CosmosDbEvent[events.Count]; |
||||
|
|
||||
|
var i = 0; |
||||
|
|
||||
|
foreach (var e in events) |
||||
|
{ |
||||
|
var mongoEvent = CosmosDbEvent.FromEventData(e); |
||||
|
|
||||
|
commitEvents[i++] = mongoEvent; |
||||
|
} |
||||
|
|
||||
|
var mongoCommit = new CosmosDbEventCommit |
||||
|
{ |
||||
|
Id = commitId, |
||||
|
Events = commitEvents, |
||||
|
EventsCount = events.Count, |
||||
|
EventStream = streamName, |
||||
|
EventStreamOffset = expectedVersion, |
||||
|
Timestamp = SystemClock.Instance.GetCurrentInstant().ToUnixTimeTicks() |
||||
|
}; |
||||
|
|
||||
|
return mongoCommit; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,150 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Text.RegularExpressions; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Azure.Documents; |
||||
|
using Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing; |
||||
|
using Newtonsoft.Json; |
||||
|
using Builder = Microsoft.Azure.Documents.ChangeFeedProcessor.ChangeFeedProcessorBuilder; |
||||
|
using Collection = Microsoft.Azure.Documents.ChangeFeedProcessor.DocumentCollectionInfo; |
||||
|
using Options = Microsoft.Azure.Documents.ChangeFeedProcessor.ChangeFeedProcessorOptions; |
||||
|
|
||||
|
#pragma warning disable IDE0017 // Simplify object initialization
|
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal sealed class CosmosDbSubscription : IEventSubscription, IChangeFeedObserverFactory, IChangeFeedObserver |
||||
|
{ |
||||
|
private readonly TaskCompletionSource<bool> processorStopRequested = new TaskCompletionSource<bool>(); |
||||
|
private readonly Task processorTask; |
||||
|
private readonly CosmosDbEventStore store; |
||||
|
private readonly Regex regex; |
||||
|
private readonly string hostName; |
||||
|
private readonly IEventSubscriber subscriber; |
||||
|
|
||||
|
public CosmosDbSubscription(CosmosDbEventStore store, IEventSubscriber subscriber, string streamFilter, string position = null) |
||||
|
{ |
||||
|
this.store = store; |
||||
|
|
||||
|
var fromBeginning = string.IsNullOrWhiteSpace(position); |
||||
|
|
||||
|
if (fromBeginning) |
||||
|
{ |
||||
|
hostName = $"squidex.{DateTime.UtcNow.Ticks.ToString()}"; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
hostName = position; |
||||
|
} |
||||
|
|
||||
|
if (!StreamFilter.IsAll(streamFilter)) |
||||
|
{ |
||||
|
regex = new Regex(streamFilter); |
||||
|
} |
||||
|
|
||||
|
this.subscriber = subscriber; |
||||
|
|
||||
|
processorTask = Task.Run(async () => |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
Collection CreateCollection(string name) |
||||
|
{ |
||||
|
var collection = new Collection(); |
||||
|
|
||||
|
collection.CollectionName = name; |
||||
|
collection.DatabaseName = store.DatabaseId; |
||||
|
collection.MasterKey = store.MasterKey; |
||||
|
collection.Uri = store.ServiceUri; |
||||
|
|
||||
|
return collection; |
||||
|
} |
||||
|
|
||||
|
var processor = |
||||
|
await new Builder() |
||||
|
.WithFeedCollection(CreateCollection(Constants.Collection)) |
||||
|
.WithLeaseCollection(CreateCollection(Constants.LeaseCollection)) |
||||
|
.WithHostName(hostName) |
||||
|
.WithProcessorOptions(new Options { StartFromBeginning = fromBeginning, LeasePrefix = hostName }) |
||||
|
.WithObserverFactory(this) |
||||
|
.BuildAsync(); |
||||
|
|
||||
|
await processor.StartAsync(); |
||||
|
await processorStopRequested.Task; |
||||
|
await processor.StopAsync(); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
await subscriber.OnErrorAsync(this, ex); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public IChangeFeedObserver CreateObserver() |
||||
|
{ |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public async Task CloseAsync(IChangeFeedObserverContext context, ChangeFeedObserverCloseReason reason) |
||||
|
{ |
||||
|
if (reason == ChangeFeedObserverCloseReason.ObserverError) |
||||
|
{ |
||||
|
await subscriber.OnErrorAsync(this, new InvalidOperationException("Change feed observer failed.")); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task OpenAsync(IChangeFeedObserverContext context) |
||||
|
{ |
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public async Task ProcessChangesAsync(IChangeFeedObserverContext context, IReadOnlyList<Document> docs, CancellationToken cancellationToken) |
||||
|
{ |
||||
|
if (!processorStopRequested.Task.IsCompleted) |
||||
|
{ |
||||
|
foreach (var document in docs) |
||||
|
{ |
||||
|
if (!processorStopRequested.Task.IsCompleted) |
||||
|
{ |
||||
|
var streamName = document.GetPropertyValue<string>("eventStream"); |
||||
|
|
||||
|
if (regex == null || regex.IsMatch(streamName)) |
||||
|
{ |
||||
|
var commit = JsonConvert.DeserializeObject<CosmosDbEventCommit>(document.ToString(), store.SerializerSettings); |
||||
|
|
||||
|
var eventStreamOffset = (int)commit.EventStreamOffset; |
||||
|
|
||||
|
foreach (var @event in commit.Events) |
||||
|
{ |
||||
|
eventStreamOffset++; |
||||
|
|
||||
|
var eventData = @event.ToEventData(); |
||||
|
|
||||
|
await subscriber.OnEventAsync(this, new StoredEvent(commit.EventStream, hostName, eventStreamOffset, eventData)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void WakeUp() |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public Task StopAsync() |
||||
|
{ |
||||
|
processorStopRequested.SetResult(true); |
||||
|
|
||||
|
return processorTask; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,156 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using Microsoft.Azure.Documents; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal static class FilterBuilder |
||||
|
{ |
||||
|
public static SqlQuerySpec AllIds(string streamName) |
||||
|
{ |
||||
|
var query = |
||||
|
$"SELECT TOP 1 " + |
||||
|
$" e.id," + |
||||
|
$" e.eventsCount " + |
||||
|
$"FROM {Constants.Collection} e " + |
||||
|
$"WHERE " + |
||||
|
$" e.eventStream = @name " + |
||||
|
$"ORDER BY e.eventStreamOffset DESC"; |
||||
|
|
||||
|
var parameters = new SqlParameterCollection |
||||
|
{ |
||||
|
new SqlParameter("@name", streamName) |
||||
|
}; |
||||
|
|
||||
|
return new SqlQuerySpec(query, parameters); |
||||
|
} |
||||
|
|
||||
|
public static SqlQuerySpec LastPosition(string streamName) |
||||
|
{ |
||||
|
var query = |
||||
|
$"SELECT TOP 1 " + |
||||
|
$" e.eventStreamOffset," + |
||||
|
$" e.eventsCount " + |
||||
|
$"FROM {Constants.Collection} e " + |
||||
|
$"WHERE " + |
||||
|
$" e.eventStream = @name " + |
||||
|
$"ORDER BY e.eventStreamOffset DESC"; |
||||
|
|
||||
|
var parameters = new SqlParameterCollection |
||||
|
{ |
||||
|
new SqlParameter("@name", streamName) |
||||
|
}; |
||||
|
|
||||
|
return new SqlQuerySpec(query, parameters); |
||||
|
} |
||||
|
|
||||
|
public static SqlQuerySpec ByStreamName(string streamName, long streamPosition = 0) |
||||
|
{ |
||||
|
var query = |
||||
|
$"SELECT * " + |
||||
|
$"FROM {Constants.Collection} e " + |
||||
|
$"WHERE " + |
||||
|
$" e.eventStream = @name " + |
||||
|
$"AND e.eventStreamOffset >= @position " + |
||||
|
$"ORDER BY e.eventStreamOffset ASC"; |
||||
|
|
||||
|
var parameters = new SqlParameterCollection |
||||
|
{ |
||||
|
new SqlParameter("@name", streamName), |
||||
|
new SqlParameter("@position", streamPosition) |
||||
|
}; |
||||
|
|
||||
|
return new SqlQuerySpec(query, parameters); |
||||
|
} |
||||
|
|
||||
|
public static SqlQuerySpec CreateByProperty(string property, object value, StreamPosition streamPosition) |
||||
|
{ |
||||
|
var filters = new List<string>(); |
||||
|
|
||||
|
var parameters = new SqlParameterCollection(); |
||||
|
|
||||
|
filters.ForPosition(parameters, streamPosition); |
||||
|
filters.ForProperty(parameters, property, value); |
||||
|
|
||||
|
return BuildQuery(filters, parameters); |
||||
|
} |
||||
|
|
||||
|
public static SqlQuerySpec CreateByFilter(string streamFilter, StreamPosition streamPosition) |
||||
|
{ |
||||
|
var filters = new List<string>(); |
||||
|
|
||||
|
var parameters = new SqlParameterCollection(); |
||||
|
|
||||
|
filters.ForPosition(parameters, streamPosition); |
||||
|
filters.ForRegex(parameters, streamFilter); |
||||
|
|
||||
|
return BuildQuery(filters, parameters); |
||||
|
} |
||||
|
|
||||
|
private static SqlQuerySpec BuildQuery(List<string> filters, SqlParameterCollection parameters) |
||||
|
{ |
||||
|
var query = $"SELECT * FROM {Constants.Collection} e WHERE {string.Join(" AND ", filters)} ORDER BY e.timestamp"; |
||||
|
|
||||
|
return new SqlQuerySpec(query, parameters); |
||||
|
} |
||||
|
|
||||
|
private static void ForProperty(this List<string> filters, SqlParameterCollection parameters, string property, object value) |
||||
|
{ |
||||
|
filters.Add($"ARRAY_CONTAINS(e.events, {{ \"header\": {{ \"{property}\": @value }} }}, true)"); |
||||
|
|
||||
|
parameters.Add(new SqlParameter("@value", value)); |
||||
|
} |
||||
|
|
||||
|
private static void ForRegex(this List<string> filters, SqlParameterCollection parameters, string streamFilter) |
||||
|
{ |
||||
|
if (!StreamFilter.IsAll(streamFilter)) |
||||
|
{ |
||||
|
if (streamFilter.Contains("^")) |
||||
|
{ |
||||
|
filters.Add($"STARTSWITH(e.eventStream, @filter)"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
filters.Add($"e.eventStream = @filter"); |
||||
|
} |
||||
|
|
||||
|
parameters.Add(new SqlParameter("@filter", streamFilter)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void ForPosition(this List<string> filters, SqlParameterCollection parameters, StreamPosition streamPosition) |
||||
|
{ |
||||
|
if (streamPosition.IsEndOfCommit) |
||||
|
{ |
||||
|
filters.Add($"e.timestamp > @time"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
filters.Add($"e.timestamp >= @time"); |
||||
|
} |
||||
|
|
||||
|
parameters.Add(new SqlParameter("@time", streamPosition.Timestamp)); |
||||
|
} |
||||
|
|
||||
|
public static EventPredicate CreateExpression(string property, object value) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(property)) |
||||
|
{ |
||||
|
var jsonValue = JsonValue.Create(value); |
||||
|
|
||||
|
return x => x.Headers.TryGetValue(property, out var p) && p.Equals(jsonValue); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
return x => true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Linq; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Azure.Documents; |
||||
|
using Microsoft.Azure.Documents.Client; |
||||
|
using Microsoft.Azure.Documents.Linq; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal static class FilterExtensions |
||||
|
{ |
||||
|
public static async Task<T> FirstOrDefaultAsync<T>(this IQueryable<T> queryable, CancellationToken ct = default) |
||||
|
{ |
||||
|
var documentQuery = queryable.AsDocumentQuery(); |
||||
|
|
||||
|
using (documentQuery) |
||||
|
{ |
||||
|
if (documentQuery.HasMoreResults) |
||||
|
{ |
||||
|
var results = await documentQuery.ExecuteNextAsync<T>(ct); |
||||
|
|
||||
|
return results.FirstOrDefault(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return default; |
||||
|
} |
||||
|
|
||||
|
public static Task QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec, Func<CosmosDbEventCommit, Task> handler, CancellationToken ct = default) |
||||
|
{ |
||||
|
var query = documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, querySpec); |
||||
|
|
||||
|
return query.QueryAsync(handler, ct); |
||||
|
} |
||||
|
|
||||
|
public static async Task QueryAsync<T>(this IQueryable<T> queryable, Func<T, Task> handler, CancellationToken ct = default) |
||||
|
{ |
||||
|
var documentQuery = queryable.AsDocumentQuery(); |
||||
|
|
||||
|
using (documentQuery) |
||||
|
{ |
||||
|
while (documentQuery.HasMoreResults && !ct.IsCancellationRequested) |
||||
|
{ |
||||
|
var items = await documentQuery.ExecuteNextAsync<T>(ct); |
||||
|
|
||||
|
foreach (var item in items) |
||||
|
{ |
||||
|
await handler(item); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
internal sealed class StreamPosition |
||||
|
{ |
||||
|
public long Timestamp { get; } |
||||
|
|
||||
|
public long CommitOffset { get; } |
||||
|
|
||||
|
public long CommitSize { get; } |
||||
|
|
||||
|
public bool IsEndOfCommit |
||||
|
{ |
||||
|
get { return CommitOffset == CommitSize - 1; } |
||||
|
} |
||||
|
|
||||
|
public StreamPosition(long timestamp, long commitOffset, long commitSize) |
||||
|
{ |
||||
|
Timestamp = timestamp; |
||||
|
|
||||
|
CommitOffset = commitOffset; |
||||
|
CommitSize = commitSize; |
||||
|
} |
||||
|
|
||||
|
public static implicit operator string(StreamPosition position) |
||||
|
{ |
||||
|
var parts = new object[] |
||||
|
{ |
||||
|
position.Timestamp, |
||||
|
position.CommitOffset, |
||||
|
position.CommitSize |
||||
|
}; |
||||
|
|
||||
|
return string.Join("-", parts); |
||||
|
} |
||||
|
|
||||
|
public static implicit operator StreamPosition(string position) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(position)) |
||||
|
{ |
||||
|
var parts = position.Split('-'); |
||||
|
|
||||
|
return new StreamPosition(long.Parse(parts[0]), long.Parse(parts[1]), long.Parse(parts[2])); |
||||
|
} |
||||
|
|
||||
|
return new StreamPosition(0, -1, -1); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Infrastructure.MongoDb |
||||
|
{ |
||||
|
public sealed class MongoDbOptions |
||||
|
{ |
||||
|
public bool IsCosmosDb { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
public static class StreamFilter |
||||
|
{ |
||||
|
public static bool IsAll(string filter) |
||||
|
{ |
||||
|
return string.IsNullOrWhiteSpace(filter) |
||||
|
|| string.Equals(filter, ".*", StringComparison.OrdinalIgnoreCase) |
||||
|
|| string.Equals(filter, "(.*)", StringComparison.OrdinalIgnoreCase) |
||||
|
|| string.Equals(filter, "(.*?)", StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,144 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 FakeItEasy; |
||||
|
using Orleans; |
||||
|
using Squidex.Domain.Apps.Core.Apps; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public class GrainTextIndexerTests |
||||
|
{ |
||||
|
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>(); |
||||
|
private readonly ITextIndexerGrain grain = A.Fake<ITextIndexerGrain>(); |
||||
|
private readonly Guid schemaId = Guid.NewGuid(); |
||||
|
private readonly Guid contentId = Guid.NewGuid(); |
||||
|
private readonly GrainTextIndexer sut; |
||||
|
|
||||
|
public GrainTextIndexerTests() |
||||
|
{ |
||||
|
A.CallTo(() => grainFactory.GetGrain<ITextIndexerGrain>(schemaId, null)) |
||||
|
.Returns(grain); |
||||
|
|
||||
|
sut = new GrainTextIndexer(grainFactory, A.Fake<ISemanticLog>()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_call_grain_when_deleting_entry() |
||||
|
{ |
||||
|
await sut.DeleteAsync(schemaId, contentId); |
||||
|
|
||||
|
A.CallTo(() => grain.DeleteAsync(contentId)) |
||||
|
.MustHaveHappened(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_catch_exception_when_deleting_failed() |
||||
|
{ |
||||
|
A.CallTo(() => grain.DeleteAsync(contentId)) |
||||
|
.Throws(new InvalidOperationException()); |
||||
|
|
||||
|
await sut.DeleteAsync(schemaId, contentId); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_call_grain_when_indexing_data() |
||||
|
{ |
||||
|
var data = new NamedContentData(); |
||||
|
var dataDraft = new NamedContentData(); |
||||
|
|
||||
|
await sut.IndexAsync(schemaId, contentId, data, dataDraft); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.That.Matches(x => x.Value.Data == data && !x.Value.IsDraft))) |
||||
|
.MustHaveHappened(); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.That.Matches(x => x.Value.Data == dataDraft && x.Value.IsDraft))) |
||||
|
.MustHaveHappened(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_not_call_grain_when_data_is_null() |
||||
|
{ |
||||
|
var dataDraft = new NamedContentData(); |
||||
|
|
||||
|
await sut.IndexAsync(schemaId, contentId, null, dataDraft); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.That.Matches(x => !x.Value.IsDraft))) |
||||
|
.MustNotHaveHappened(); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.That.Matches(x => x.Value.Data == dataDraft && x.Value.IsDraft))) |
||||
|
.MustHaveHappened(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_not_call_grain_when_data_draft_is_null() |
||||
|
{ |
||||
|
var data = new NamedContentData(); |
||||
|
|
||||
|
await sut.IndexAsync(schemaId, contentId, data, null); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.That.Matches(x => x.Value.Data == data && !x.Value.IsDraft))) |
||||
|
.MustHaveHappened(); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.That.Matches(x => x.Value.IsDraft))) |
||||
|
.MustNotHaveHappened(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_catch_exception_when_indexing_failed() |
||||
|
{ |
||||
|
var data = new NamedContentData(); |
||||
|
|
||||
|
A.CallTo(() => grain.IndexAsync(contentId, A<J<IndexData>>.Ignored)) |
||||
|
.Throws(new InvalidOperationException()); |
||||
|
|
||||
|
await sut.IndexAsync(schemaId, contentId, data, null); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_call_grain_when_searching() |
||||
|
{ |
||||
|
var foundIds = new List<Guid> { Guid.NewGuid() }; |
||||
|
|
||||
|
A.CallTo(() => grain.SearchAsync("Search", A<SearchContext>.Ignored)) |
||||
|
.Returns(foundIds); |
||||
|
|
||||
|
var ids = await sut.SearchAsync("Search", GetApp(), schemaId, true); |
||||
|
|
||||
|
Assert.Equal(foundIds, ids); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_not_call_grain_when_input_is_empty() |
||||
|
{ |
||||
|
var ids = await sut.SearchAsync(string.Empty, GetApp(), schemaId, false); |
||||
|
|
||||
|
Assert.Null(ids); |
||||
|
|
||||
|
A.CallTo(() => grain.SearchAsync(A<string>.Ignored, A<SearchContext>.Ignored)) |
||||
|
.MustNotHaveHappened(); |
||||
|
} |
||||
|
|
||||
|
private static IAppEntity GetApp() |
||||
|
{ |
||||
|
var app = A.Fake<IAppEntity>(); |
||||
|
|
||||
|
A.CallTo(() => app.LanguagesConfig).Returns(LanguagesConfig.Build(Language.EN, Language.DE)); |
||||
|
|
||||
|
return app; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,178 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents.Text |
||||
|
{ |
||||
|
public class TextIndexerGrainTests : IDisposable |
||||
|
{ |
||||
|
private readonly Guid schemaId = Guid.NewGuid(); |
||||
|
private readonly List<Guid> ids1 = new List<Guid> { Guid.NewGuid() }; |
||||
|
private readonly List<Guid> ids2 = new List<Guid> { Guid.NewGuid() }; |
||||
|
private readonly SearchContext context; |
||||
|
private readonly IAssetStore assetStore = new MemoryAssetStore(); |
||||
|
private readonly TextIndexerGrain sut; |
||||
|
|
||||
|
public TextIndexerGrainTests() |
||||
|
{ |
||||
|
context = new SearchContext |
||||
|
{ |
||||
|
Languages = new HashSet<string> { "de", "en" } |
||||
|
}; |
||||
|
|
||||
|
sut = new TextIndexerGrain(assetStore); |
||||
|
sut.ActivateAsync(schemaId).Wait(); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
sut.OnDeactivateAsync().Wait(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_read_index_and_retrieve() |
||||
|
{ |
||||
|
await AddInvariantContent(); |
||||
|
|
||||
|
await sut.DeactivateAsync(); |
||||
|
|
||||
|
var other = new TextIndexerGrain(assetStore); |
||||
|
try |
||||
|
{ |
||||
|
await other.ActivateAsync(schemaId); |
||||
|
|
||||
|
var foundHello = await other.SearchAsync("Hello", context); |
||||
|
var foundWorld = await other.SearchAsync("World", context); |
||||
|
|
||||
|
Assert.Equal(ids1, foundHello); |
||||
|
Assert.Equal(ids2, foundWorld); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
await other.OnDeactivateAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_index_invariant_content_and_retrieve() |
||||
|
{ |
||||
|
await AddInvariantContent(); |
||||
|
|
||||
|
var foundHello = await sut.SearchAsync("Hello", context); |
||||
|
var foundWorld = await sut.SearchAsync("World", context); |
||||
|
|
||||
|
Assert.Equal(ids1, foundHello); |
||||
|
Assert.Equal(ids2, foundWorld); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_index_invariant_content_and_retrieve_with_fuzzy() |
||||
|
{ |
||||
|
await AddInvariantContent(); |
||||
|
|
||||
|
var foundHello = await sut.SearchAsync("helo~", context); |
||||
|
var foundWorld = await sut.SearchAsync("wold~", context); |
||||
|
|
||||
|
Assert.Equal(ids1, foundHello); |
||||
|
Assert.Equal(ids2, foundWorld); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_delete_documents_from_index() |
||||
|
{ |
||||
|
await AddInvariantContent(); |
||||
|
|
||||
|
await sut.DeleteAsync(ids1[0]); |
||||
|
await sut.FlushAsync(); |
||||
|
|
||||
|
var helloIds = await sut.SearchAsync("Hello", context); |
||||
|
|
||||
|
var worldIds = await sut.SearchAsync("World", context); |
||||
|
|
||||
|
Assert.Empty(helloIds); |
||||
|
Assert.Equal(ids2, worldIds); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_index_localized_content_and_retrieve() |
||||
|
{ |
||||
|
await AddLocalizedContent(); |
||||
|
|
||||
|
var german1 = await sut.SearchAsync("Stadt", context); |
||||
|
var german2 = await sut.SearchAsync("and", context); |
||||
|
|
||||
|
var germanStopwordsIds = await sut.SearchAsync("und", context); |
||||
|
|
||||
|
Assert.Equal(ids1, german1); |
||||
|
Assert.Equal(ids1, german2); |
||||
|
Assert.Equal(ids2, germanStopwordsIds); |
||||
|
|
||||
|
var english1 = await sut.SearchAsync("City", context); |
||||
|
var english2 = await sut.SearchAsync("und", context); |
||||
|
|
||||
|
var englishStopwordsIds = await sut.SearchAsync("and", context); |
||||
|
|
||||
|
Assert.Equal(ids2, english1); |
||||
|
Assert.Equal(ids2, english2); |
||||
|
Assert.Equal(ids1, englishStopwordsIds); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_throw_exception_for_invalid_query() |
||||
|
{ |
||||
|
await AddInvariantContent(); |
||||
|
|
||||
|
await Assert.ThrowsAsync<ValidationException>(() => sut.SearchAsync("~hello", context)); |
||||
|
} |
||||
|
|
||||
|
private async Task AddLocalizedContent() |
||||
|
{ |
||||
|
var germanData = |
||||
|
new NamedContentData() |
||||
|
.AddField("localized", |
||||
|
new ContentFieldData() |
||||
|
.AddValue("de", "Stadt und Umgebung and whatever")); |
||||
|
|
||||
|
var englishData = |
||||
|
new NamedContentData() |
||||
|
.AddField("localized", |
||||
|
new ContentFieldData() |
||||
|
.AddValue("en", "City and Surroundings und sonstiges")); |
||||
|
|
||||
|
await sut.IndexAsync(ids1[0], new IndexData { Data = germanData }); |
||||
|
await sut.IndexAsync(ids2[0], new IndexData { Data = englishData }); |
||||
|
await sut.FlushAsync(); |
||||
|
} |
||||
|
|
||||
|
private async Task AddInvariantContent() |
||||
|
{ |
||||
|
var data1 = |
||||
|
new NamedContentData() |
||||
|
.AddField("test", |
||||
|
new ContentFieldData() |
||||
|
.AddValue("iv", "Hello")); |
||||
|
|
||||
|
var data2 = |
||||
|
new NamedContentData() |
||||
|
.AddField("test", |
||||
|
new ContentFieldData() |
||||
|
.AddValue("iv", "World")); |
||||
|
|
||||
|
await sut.IndexAsync(ids1[0], new IndexData { Data = data1 }); |
||||
|
await sut.IndexAsync(ids2[0], new IndexData { Data = data2 }); |
||||
|
|
||||
|
await sut.FlushAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Microsoft.Azure.Documents.Client; |
||||
|
using Squidex.Infrastructure.TestHelpers; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
public sealed class CosmosDbEventStoreFixture : IDisposable |
||||
|
{ |
||||
|
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; |
||||
|
private const string EmulatorUri = "https://localhost:8081"; |
||||
|
private readonly DocumentClient client; |
||||
|
|
||||
|
public CosmosDbEventStore EventStore { get; } |
||||
|
|
||||
|
public CosmosDbEventStoreFixture() |
||||
|
{ |
||||
|
client = new DocumentClient(new Uri(EmulatorUri), EmulatorKey, JsonHelper.DefaultSettings()); |
||||
|
|
||||
|
EventStore = new CosmosDbEventStore(client, EmulatorKey, "Test", JsonHelper.DefaultSettings()); |
||||
|
EventStore.InitializeAsync().Wait(); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
client.DeleteDatabaseAsync(UriFactory.CreateDatabaseUri("Test")).Wait(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.EventSourcing |
||||
|
{ |
||||
|
[Trait("Category", "Dependencies")] |
||||
|
public class CosmosDbEventStoreTests : EventStoreTests<CosmosDbEventStore>, IClassFixture<CosmosDbEventStoreFixture> |
||||
|
{ |
||||
|
private readonly CosmosDbEventStoreFixture fixture; |
||||
|
|
||||
|
protected override int SubscriptionDelayInMs { get; } = 1000; |
||||
|
|
||||
|
public CosmosDbEventStoreTests(CosmosDbEventStoreFixture fixture) |
||||
|
{ |
||||
|
this.fixture = fixture; |
||||
|
} |
||||
|
|
||||
|
public override CosmosDbEventStore CreateStore() |
||||
|
{ |
||||
|
return fixture.EventStore; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue