diff --git a/backend/src/Logitar.Cms.Contracts/Contents/ContentSort.cs b/backend/src/Logitar.Cms.Contracts/Contents/ContentSort.cs new file mode 100644 index 0000000..3763e7a --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Contents/ContentSort.cs @@ -0,0 +1,7 @@ +namespace Logitar.Cms.Contracts.Contents; + +public enum ContentSort +{ + UniqueName, + UpdatedOn +} diff --git a/backend/src/Logitar.Cms.Contracts/Contents/ContentSortOption.cs b/backend/src/Logitar.Cms.Contracts/Contents/ContentSortOption.cs new file mode 100644 index 0000000..851cbc7 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Contents/ContentSortOption.cs @@ -0,0 +1,20 @@ +using Logitar.Cms.Contracts.Search; + +namespace Logitar.Cms.Contracts.Contents; + +public record ContentSortOption : SortOption +{ + public new ContentSort Field + { + get => Enum.Parse(base.Field); + set => base.Field = value.ToString(); + } + + public ContentSortOption() : this(ContentSort.UpdatedOn, isDescending: true) + { + } + + public ContentSortOption(ContentSort field, bool isDescending = false) : base(field.ToString(), isDescending) + { + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Contents/SearchContentsPayload.cs b/backend/src/Logitar.Cms.Contracts/Contents/SearchContentsPayload.cs new file mode 100644 index 0000000..13da41f --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Contents/SearchContentsPayload.cs @@ -0,0 +1,11 @@ +using Logitar.Cms.Contracts.Search; + +namespace Logitar.Cms.Contracts.Contents; + +public record SearchContentsPayload : SearchPayload +{ + public Guid? ContentTypeId { get; set; } + public Guid? LanguageId { get; set; } + + public new List Sort { get; set; } = []; +} diff --git a/backend/src/Logitar.Cms.Core/Contents/IContentQuerier.cs b/backend/src/Logitar.Cms.Core/Contents/IContentQuerier.cs index 47dc020..2db2d65 100644 --- a/backend/src/Logitar.Cms.Core/Contents/IContentQuerier.cs +++ b/backend/src/Logitar.Cms.Core/Contents/IContentQuerier.cs @@ -1,4 +1,5 @@ using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; namespace Logitar.Cms.Core.Contents; @@ -7,4 +8,6 @@ public interface IContentQuerier Task ReadAsync(ContentAggregate content, CancellationToken cancellationToken = default); Task ReadAsync(ContentId id, CancellationToken cancellationToken = default); Task ReadAsync(Guid id, CancellationToken cancellationToken = default); + + Task> SearchAsync(SearchContentsPayload payload, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs b/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs index 64b5cb7..2f327a4 100644 --- a/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs +++ b/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs @@ -6,6 +6,7 @@ namespace Logitar.Cms.Core.Contents; public interface IContentRepository { + Task> LoadAsync(CancellationToken cancellationToken = default); Task LoadAsync(ContentId id, CancellationToken cancellationToken = default); Task LoadAsync(ContentTypeId contentTypeId, LanguageId? languageId, UniqueNameUnit uniqueName, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Cms.Core/Contents/Queries/SearchContentsQuery.cs b/backend/src/Logitar.Cms.Core/Contents/Queries/SearchContentsQuery.cs new file mode 100644 index 0000000..625ac75 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Contents/Queries/SearchContentsQuery.cs @@ -0,0 +1,7 @@ +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; +using MediatR; + +namespace Logitar.Cms.Core.Contents.Queries; + +public record SearchContentsQuery(SearchContentsPayload Payload) : IRequest>; diff --git a/backend/src/Logitar.Cms.Core/Contents/Queries/SearchContentsQueryHandler.cs b/backend/src/Logitar.Cms.Core/Contents/Queries/SearchContentsQueryHandler.cs new file mode 100644 index 0000000..87c245f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Contents/Queries/SearchContentsQueryHandler.cs @@ -0,0 +1,20 @@ +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; +using MediatR; + +namespace Logitar.Cms.Core.Contents.Queries; + +internal class SearchContentsQueryHandler : IRequestHandler> +{ + private readonly IContentQuerier _contentTypeQuerier; + + public SearchContentsQueryHandler(IContentQuerier contentTypeQuerier) + { + _contentTypeQuerier = contentTypeQuerier; + } + + public async Task> Handle(SearchContentsQuery query, CancellationToken cancellationToken) + { + return await _contentTypeQuerier.SearchAsync(query.Payload, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs index 86dc11b..9e7b5c9 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs @@ -89,6 +89,18 @@ public ContentItem ToContentItem(ContentItemEntity source) return destination; } + public ContentLocale ToContentLocale(ContentLocaleEntity source) + { + if (source.ContentItem == null) + { + throw new ArgumentException($"The {nameof(source.ContentItem)} is required.", nameof(source)); + } + + ContentItem item = ToContentItem(source.ContentItem); + + return item.Locales.SingleOrDefault() ?? item.Invariant; + } + private ContentLocale ToContentLocale(ContentLocaleEntity source, ContentItem item) { if (source.LanguageId.HasValue && source.Language == null) diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentQuerier.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentQuerier.cs index dd19524..a66384e 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentQuerier.cs +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ContentQuerier.cs @@ -1,8 +1,12 @@ using Logitar.Cms.Contracts.Actors; using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; using Logitar.Cms.Core.Contents; +using Logitar.Cms.Core.ContentTypes; +using Logitar.Cms.Core.Localization; using Logitar.Cms.EntityFrameworkCore.Actors; using Logitar.Cms.EntityFrameworkCore.Entities; +using Logitar.Data; using Logitar.EventSourcing; using Logitar.Identity.EntityFrameworkCore.Relational; using Microsoft.EntityFrameworkCore; @@ -13,6 +17,7 @@ internal class ContentQuerier : IContentQuerier { private readonly IActorService _actorService; private readonly DbSet _contentItems; + private readonly DbSet _contentLocales; private readonly ISearchHelper _searchHelper; private readonly ISqlHelper _sqlHelper; @@ -20,6 +25,7 @@ public ContentQuerier(IActorService actorService, CmsContext context, ISearchHel { _actorService = actorService; _contentItems = context.ContentItems; + _contentLocales = context.ContentLocales; _searchHelper = searchHelper; _sqlHelper = sqlHelper; } @@ -45,6 +51,60 @@ public async Task ReadAsync(ContentAggregate content, CancellationT return content == null ? null : await MapAsync(content, cancellationToken); } + public async Task> SearchAsync(SearchContentsPayload payload, CancellationToken cancellationToken) + { + IQueryBuilder builder = _sqlHelper.QueryFrom(CmsDb.ContentLocales.Table).SelectAll(CmsDb.ContentLocales.Table) + .Join(CmsDb.ContentItems.ContentItemId, CmsDb.ContentLocales.ContentItemId) + .Join(CmsDb.ContentTypes.ContentTypeId, CmsDb.ContentItems.ContentTypeId) + .LeftJoin(CmsDb.Languages.LanguageId, CmsDb.ContentLocales.LanguageId) + .ApplyIdInFilter(CmsDb.ContentItems.AggregateId, payload); + _searchHelper.ApplyTextSearch(builder, payload.Search, CmsDb.ContentLocales.UniqueName); + + if (payload.ContentTypeId.HasValue) + { + ContentTypeId contentTypeId = new(payload.ContentTypeId.Value); + builder.Where(CmsDb.ContentTypes.AggregateId, Operators.IsEqualTo(contentTypeId.Value)); + } + + LanguageId? languageId = payload.LanguageId.HasValue ? new(payload.LanguageId.Value) : null; + builder.Where(CmsDb.Languages.AggregateId, languageId.HasValue ? Operators.IsEqualTo(languageId.Value.Value) : Operators.IsNull()); + + IQueryable query = _contentLocales.FromQuery(builder).AsNoTracking() + .Include(x => x.ContentItem).ThenInclude(x => x!.ContentType) + .Include(x => x.Language); + + long total = await query.LongCountAsync(cancellationToken); + + IOrderedQueryable? ordered = null; + if (payload.Sort != null) + { + foreach (ContentSortOption sort in payload.Sort) + { + switch (sort.Field) + { + case ContentSort.UniqueName: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.UniqueName) : query.OrderBy(x => x.UniqueName)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.UniqueName) : ordered.ThenBy(x => x.UniqueName)); + break; + case ContentSort.UpdatedOn: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.UpdatedOn) : query.OrderBy(x => x.UpdatedOn)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.UpdatedOn) : ordered.ThenBy(x => x.UpdatedOn)); + break; + } + } + } + query = ordered ?? query; + + query = query.ApplyPaging(payload); + + ContentLocaleEntity[] locales = await query.ToArrayAsync(cancellationToken); + IEnumerable items = await MapAsync(locales, cancellationToken); + + return new SearchResults(items, total); + } + private async Task MapAsync(ContentItemEntity content, CancellationToken cancellationToken) { return (await MapAsync([content], cancellationToken)).Single(); @@ -57,4 +117,13 @@ private async Task> MapAsync(IEnumerable> MapAsync(IEnumerable locales, CancellationToken cancellationToken) + { + IEnumerable actorIds = locales.SelectMany(locale => locale.GetActorIds(includeItem: true)); + IReadOnlyCollection actors = await _actorService.FindAsync(actorIds, cancellationToken); + Mapper mapper = new(actors); + + return locales.Select(mapper.ToContentLocale).ToArray(); + } } diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentRepository.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentRepository.cs index 190c8d8..791cf91 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentRepository.cs +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ContentRepository.cs @@ -22,6 +22,11 @@ public ContentRepository(IEventBus eventBus, EventContext eventContext, IEventSe _sqlHelper = sqlHelper; } + public async Task> LoadAsync(CancellationToken cancellationToken) + { + return (await base.LoadAsync(cancellationToken)).ToArray(); + } + public async Task LoadAsync(ContentId id, CancellationToken cancellationToken) { return await LoadAsync(id.AggregateId, cancellationToken); diff --git a/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs b/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs index 28d7c9f..bdf77ff 100644 --- a/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs +++ b/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs @@ -1,8 +1,10 @@ using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; using Logitar.Cms.Core; using Logitar.Cms.Core.Contents.Commands; using Logitar.Cms.Core.Contents.Queries; using Logitar.Cms.Web.Extensions; +using Logitar.Cms.Web.Models.Contents; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -45,6 +47,13 @@ public async Task> SaveLocaleAsync(Guid id, [FromBody] return contentItem == null ? NotFound() : Ok(contentItem); } + [HttpGet] + public async Task>> SearchAsync([FromQuery] SearchContentsParameters parameters, CancellationToken cancellationToken) + { + SearchResults contentTypes = await _pipeline.ExecuteAsync(new SearchContentsQuery(parameters.ToPayload()), cancellationToken); + return Ok(contentTypes); + } + [HttpPatch("{id}")] public async Task> UpdateLocaleAsync(Guid id, [FromBody] UpdateContentLocalePayload payload, Guid? languageId, CancellationToken cancellationToken) { diff --git a/backend/src/Logitar.Cms.Web/Models/Contents/SearchContentsParameters.cs b/backend/src/Logitar.Cms.Web/Models/Contents/SearchContentsParameters.cs new file mode 100644 index 0000000..8ddf985 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Models/Contents/SearchContentsParameters.cs @@ -0,0 +1,41 @@ +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; +using Logitar.Cms.Web.Models.Search; +using Microsoft.AspNetCore.Mvc; + +namespace Logitar.Cms.Web.Models.Contents; + +public record SearchContentsParameters : SearchParameters +{ + [FromQuery(Name = "type")] + public Guid? ContentTypeId { get; set; } + + [FromQuery(Name = "language")] + public Guid? LanguageId { get; set; } + + public SearchContentsPayload ToPayload() + { + SearchContentsPayload payload = new() + { + ContentTypeId = ContentTypeId, + LanguageId = LanguageId + }; + + FillPayload(payload); + + List? sortOptions = ((SearchPayload)payload).Sort; + if (sortOptions != null) + { + payload.Sort = new List(capacity: sortOptions.Count); + foreach (SortOption sort in sortOptions) + { + if (Enum.TryParse(sort.Field, out ContentSort content)) + { + payload.Sort.Add(new ContentSortOption(content, sort.IsDescending)); + } + } + } + + return payload; + } +} diff --git a/backend/tests/Logitar.Cms.IntegrationTests/Core/Contents/Queries/SearchContentsQueryTests.cs b/backend/tests/Logitar.Cms.IntegrationTests/Core/Contents/Queries/SearchContentsQueryTests.cs new file mode 100644 index 0000000..b04a5e8 --- /dev/null +++ b/backend/tests/Logitar.Cms.IntegrationTests/Core/Contents/Queries/SearchContentsQueryTests.cs @@ -0,0 +1,137 @@ +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Contracts.Search; +using Logitar.Cms.Core.ContentTypes; +using Logitar.Cms.Core.Localization; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Shared; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.Core.Contents.Queries; + +[Trait(Traits.Category, Categories.Integration)] +public class SearchContentsQueryTests : IntegrationTests +{ + private readonly IContentRepository _contentRepository; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly ILanguageRepository _languageRepository; + + private readonly LanguageAggregate _canadianEnglish; + private readonly LanguageAggregate _canadianFrench; + + private readonly ContentTypeAggregate _blogArticleType; + private readonly ContentTypeAggregate _blogAuthorType; + private readonly ContentTypeAggregate _blogCategoryType; + + private readonly ContentAggregate _blogAuthor; + private readonly ContentAggregate _blogArticle1; + private readonly ContentAggregate _blogArticle2; + private readonly ContentAggregate _blogArticle3; + private readonly ContentAggregate _blogArticle4; + private readonly ContentAggregate _blogCategory; + + public SearchContentsQueryTests() : base() + { + _contentRepository = ServiceProvider.GetRequiredService(); + _contentTypeRepository = ServiceProvider.GetRequiredService(); + _languageRepository = ServiceProvider.GetRequiredService(); + + _canadianEnglish = new(new LocaleUnit("en-CA")); + _canadianFrench = new(new LocaleUnit("fr-CA")); + + _blogArticleType = new(new IdentifierUnit("BlogArticle"), isInvariant: false); + _blogAuthorType = new(new IdentifierUnit("BlogAuthor"), isInvariant: true); + _blogCategoryType = new(new IdentifierUnit("BlogCategory"), isInvariant: false); + + IUniqueNameSettings uniqueNameSettings = ContentAggregate.UniqueNameSettings; + _blogAuthor = new(_blogAuthorType, new ContentLocaleUnit(new UniqueNameUnit(uniqueNameSettings, "red-deer"))); + _blogArticle1 = new(_blogArticleType, new ContentLocaleUnit(new UniqueNameUnit(uniqueNameSettings, "prolongez-lete-avec-une-acura-nsx-coupe"))); + _blogArticle1.SetLocale(_canadianFrench, _blogArticle1.Invariant); + _blogArticle2 = new(_blogArticleType, new ContentLocaleUnit(new UniqueNameUnit(uniqueNameSettings, "comparaison-acura-integra-2024-vs-bmw-serie-2-decouvrez-les-avantages-dacura"))); + _blogArticle2.SetLocale(_canadianFrench, _blogArticle2.Invariant); + _blogArticle3 = new(_blogArticleType, new ContentLocaleUnit(new UniqueNameUnit(uniqueNameSettings, "decouvrir-acura-luxe-origine-et-durabilite-explores"))); + _blogArticle3.SetLocale(_canadianFrench, _blogArticle3.Invariant); + _blogArticle4 = new(_blogArticleType, new ContentLocaleUnit(new UniqueNameUnit(uniqueNameSettings, "un-vus-electrisant-le-zdx-2024-debarque-chez-nous"))); + _blogArticle4.SetLocale(_canadianFrench, _blogArticle4.Invariant); + _blogCategory = new(_blogCategoryType, new ContentLocaleUnit(new UniqueNameUnit(uniqueNameSettings, "acura-news"))); + _blogCategory.SetLocale(_canadianEnglish, _blogCategory.Invariant); + _blogCategory.SetLocale(_canadianFrench, _blogCategory.Invariant); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + + await _languageRepository.SaveAsync([_canadianEnglish, _canadianFrench]); + await _contentTypeRepository.SaveAsync([_blogArticleType, _blogAuthorType, _blogCategoryType]); + await _contentRepository.SaveAsync([_blogAuthor, _blogArticle1, _blogArticle2, _blogArticle3, _blogArticle4, _blogCategory]); + } + + [Fact(DisplayName = "It should return empty search results.")] + public async Task It_should_return_empty_search_results() + { + SearchContentsPayload payload = new() + { + Search = new TextSearch([new SearchTerm("%test%")]) + }; + + SearchContentsQuery query = new(payload); + SearchResults results = await Pipeline.ExecuteAsync(query); + Assert.Equal(0, results.Total); + Assert.Empty(results.Items); + } + + [Fact(DisplayName = "It should return the correct search results (invariant).")] + public async Task It_should_return_the_correct_search_results_invariant() + { + SearchContentsPayload payload = new() + { + ContentTypeId = _blogArticleType.Id.ToGuid(), + LanguageId = null, + Ids = (await _contentRepository.LoadAsync()).Select(x => x.Id.ToGuid()).ToList(), + Search = new TextSearch([new SearchTerm("%acura%"), new SearchTerm("%red%")], SearchOperator.Or), + Sort = [new ContentSortOption(ContentSort.UniqueName, isDescending: false)], + Skip = 1, + Limit = 1 + }; + + payload.Ids.Remove(_blogArticle3.Id.ToGuid()); + payload.Ids.Add(Guid.Empty); + + SearchContentsQuery query = new(payload); + SearchResults results = await Pipeline.ExecuteAsync(query); + Assert.Equal(2, results.Total); + Assert.Equal(payload.Limit, results.Items.Count); + + ContentLocale locale = Assert.Single(results.Items); + Assert.Null(locale.Language); + Assert.Equal(_blogArticle1.Id.ToGuid(), locale.Item.Id); + } + + [Fact(DisplayName = "It should return the correct search results (locale).")] + public async Task It_should_return_the_correct_search_results_locale() + { + SearchContentsPayload payload = new() + { + ContentTypeId = _blogArticleType.Id.ToGuid(), + LanguageId = _canadianFrench.Id.ToGuid(), + Ids = (await _contentRepository.LoadAsync()).Select(x => x.Id.ToGuid()).ToList(), + Search = new TextSearch([new SearchTerm("%acura%"), new SearchTerm("%red%")], SearchOperator.Or), + Sort = [new ContentSortOption(ContentSort.UniqueName, isDescending: false)], + Skip = 1, + Limit = 1 + }; + + payload.Ids.Remove(_blogArticle3.Id.ToGuid()); + payload.Ids.Add(Guid.Empty); + + SearchContentsQuery query = new(payload); + SearchResults results = await Pipeline.ExecuteAsync(query); + Assert.Equal(2, results.Total); + Assert.Equal(payload.Limit, results.Items.Count); + + ContentLocale locale = Assert.Single(results.Items); + Assert.NotNull(locale.Language); + Assert.Equal(_canadianFrench.Id.ToGuid(), locale.Language.Id); + Assert.Equal(_blogArticle1.Id.ToGuid(), locale.Item.Id); + } +}