// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.Text { public abstract class MongoTextIndexBase : MongoRepositoryBase>, ITextIndex, IDeleter where T : class { private readonly CommandFactory commandFactory; protected sealed class MongoTextResult { [BsonId] [BsonElement] public string Id { get; set; } [BsonRequired] [BsonElement("_ci")] public DomainId ContentId { get; set; } [BsonIgnoreIfDefault] [BsonElement("score")] public double Score { get; set; } } protected MongoTextIndexBase(IMongoDatabase database) : base(database) { #pragma warning disable MA0056 // Do not call overridable members in constructor commandFactory = new CommandFactory(BuildTexts); #pragma warning restore MA0056 // Do not call overridable members in constructor } protected override Task SetupCollectionAsync(IMongoCollection> collection, CancellationToken ct) { return collection.Indexes.CreateManyAsync(new[] { new CreateIndexModel>( Index.Ascending(x => x.DocId)), new CreateIndexModel>( Index .Ascending(x => x.AppId) .Ascending(x => x.ServeAll) .Ascending(x => x.ServePublished) .Ascending(x => x.SchemaId) .Ascending(x => x.GeoField) .Geo2DSphere(x => x.GeoObject)) }, ct); } protected override string CollectionName() { return "TextIndex"; } protected abstract T BuildTexts(Dictionary source); async Task IDeleter.DeleteAppAsync(IAppEntity app, CancellationToken ct) { await Collection.DeleteManyAsync(Filter.Eq(x => x.AppId, app.Id), ct); } public async virtual Task ExecuteAsync(IndexCommand[] commands, CancellationToken ct = default) { var writes = new List>>(commands.Length); foreach (var command in commands) { commandFactory.CreateCommands(command, writes); } if (writes.Count == 0) { return; } try { await Collection.BulkWriteAsync(writes, BulkUnordered, ct); } catch (MongoBulkWriteException ex) { // Ignore invalid geo data. if (ex.WriteErrors.Any(error => error.Code != MongoDbErrorCodes.Errror16755_InvalidGeoData)) { throw; } } } public virtual async Task?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, CancellationToken ct = default) { Guard.NotNull(app); Guard.NotNull(query); var findFilter = Filter.And( Filter.Eq(x => x.AppId, app.Id), Filter.Eq(x => x.SchemaId, query.SchemaId), Filter_ByScope(scope), Filter.GeoWithinCenterSphere(x => x.GeoObject, query.Longitude, query.Latitude, query.Radius / 6378100)); var byGeo = await GetCollection(scope).Find(findFilter).Limit(query.Take) .Project(Projection.Include(x => x.ContentId)) .ToListAsync(ct); return byGeo.Select(x => x.ContentId).ToList(); } public virtual async Task?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, CancellationToken ct = default) { Guard.NotNull(app); Guard.NotNull(query); var (search, take) = query; if (string.IsNullOrWhiteSpace(search)) { return null; } var result = new List<(DomainId Id, double Score)>(); if (query.RequiredSchemaIds?.Count > 0) { await SearchBySchemaAsync(result, search, app, query.RequiredSchemaIds, scope, take, 1, ct); } else if (query.PreferredSchemaId == null) { await SearchByAppAsync(result, search, app, scope, take, 1, ct); } else { var halfBucket = take / 2; var schemaIds = Enumerable.Repeat(query.PreferredSchemaId.Value, 1); await SearchBySchemaAsync(result, search, app, schemaIds, scope, halfBucket, 1.1, ct); await SearchByAppAsync(result, search, app, scope, halfBucket, 1, ct); } return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList(); } private Task SearchBySchemaAsync(List<(DomainId, double)> result, string text, IAppEntity app, IEnumerable schemaIds, SearchScope scope, int take, double factor, CancellationToken ct = default) { var filter = Filter.And( Filter.Eq(x => x.AppId, app.Id), Filter.In(x => x.SchemaId, schemaIds), Filter_ByScope(scope), Filter.Text(text, "none")); return SearchAsync(result, filter, scope, take, factor, ct); } private Task SearchByAppAsync(List<(DomainId, double)> result, string text, IAppEntity app, SearchScope scope, int take, double factor, CancellationToken ct = default) { var filter = Filter.And( Filter.Eq(x => x.AppId, app.Id), Filter.Exists(x => x.SchemaId), Filter_ByScope(scope), Filter.Text(text, "none")); return SearchAsync(result, filter, scope, take, factor, ct); } private async Task SearchAsync(List<(DomainId, double)> result, FilterDefinition> filter, SearchScope scope, int take, double factor, CancellationToken ct = default) { var byText = await GetCollection(scope).Find(filter).Limit(take) .Project(Projection.Include(x => x.ContentId).MetaTextScore("score")).Sort(Sort.MetaTextScore("score")) .ToListAsync(ct); result.AddRange(byText.Select(x => (x.ContentId, x.Score * factor))); } private static FilterDefinition> Filter_ByScope(SearchScope scope) { if (scope == SearchScope.All) { return Filter.Eq(x => x.ServeAll, true); } else { return Filter.Eq(x => x.ServePublished, true); } } private IMongoCollection> GetCollection(SearchScope scope) { if (scope == SearchScope.All) { return Collection; } else { return Collection.WithReadPreference(ReadPreference.Secondary); } } } }