From cc263abddef114dea45b7ae03f46a2a14ecfc6ca Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Fri, 26 Apr 2024 16:56:28 -0400 Subject: [PATCH] Implemented project search. --- .../Projects/IProjectQuerier.cs | 3 + .../Projects/Queries/SearchProjectsQuery.cs | 7 +++ .../Queries/SearchProjectsQueryHandler.cs | 20 +++++++ .../Projects/ProjectSort.cs | 8 +++ .../Projects/ProjectSortOption.cs | 19 ++++++ .../Projects/SearchProjectsPayload.cs | 8 +++ .../Search/SearchOperator.cs | 7 +++ .../Search/SearchPayload.cs | 12 ++++ .../Search/SearchResults.cs | 28 +++++++++ .../Search/SearchTerm.cs | 15 +++++ .../Search/SortOption.cs | 17 ++++++ .../Search/TextSearch.cs | 18 ++++++ .../Projects/IProjectRepository.cs | 1 + .../SqlServerHelper.cs | 4 +- .../ISqlHelper.cs | 2 + .../Logitar.Master.EntityFrameworkCore.csproj | 1 + .../Queriers/ProjectQuerier.cs | 52 ++++++++++++++++- .../QueryingExtensions.cs | 28 +++++++++ .../Repositories/ProjectRepository.cs | 5 ++ .../SqlHelper.cs | 45 ++++++++++++++ .../Queries/SearchProjectQueryTests.cs | 58 +++++++++++++++++++ 21 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQuery.cs create mode 100644 backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQueryHandler.cs create mode 100644 backend/src/Logitar.Master.Contracts/Projects/ProjectSort.cs create mode 100644 backend/src/Logitar.Master.Contracts/Projects/ProjectSortOption.cs create mode 100644 backend/src/Logitar.Master.Contracts/Projects/SearchProjectsPayload.cs create mode 100644 backend/src/Logitar.Master.Contracts/Search/SearchOperator.cs create mode 100644 backend/src/Logitar.Master.Contracts/Search/SearchPayload.cs create mode 100644 backend/src/Logitar.Master.Contracts/Search/SearchResults.cs create mode 100644 backend/src/Logitar.Master.Contracts/Search/SearchTerm.cs create mode 100644 backend/src/Logitar.Master.Contracts/Search/SortOption.cs create mode 100644 backend/src/Logitar.Master.Contracts/Search/TextSearch.cs create mode 100644 backend/src/Logitar.Master.EntityFrameworkCore/SqlHelper.cs create mode 100644 backend/tests/Logitar.Master.IntegrationTests/Application/Projects/Queries/SearchProjectQueryTests.cs diff --git a/backend/src/Logitar.Master.Application/Projects/IProjectQuerier.cs b/backend/src/Logitar.Master.Application/Projects/IProjectQuerier.cs index 38ad147..9b8fd2d 100644 --- a/backend/src/Logitar.Master.Application/Projects/IProjectQuerier.cs +++ b/backend/src/Logitar.Master.Application/Projects/IProjectQuerier.cs @@ -1,4 +1,5 @@ using Logitar.Master.Contracts.Projects; +using Logitar.Master.Contracts.Search; using Logitar.Master.Domain.Projects; namespace Logitar.Master.Application.Projects; @@ -9,4 +10,6 @@ public interface IProjectQuerier Task ReadAsync(ProjectId id, CancellationToken cancellationToken = default); Task ReadAsync(Guid id, CancellationToken cancellationToken = default); Task ReadAsync(string uniqueKey, CancellationToken cancellationToken = default); + + Task> SearchAsync(SearchProjectsPayload payload, CancellationToken cancellationToken = default); } diff --git a/backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQuery.cs b/backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQuery.cs new file mode 100644 index 0000000..a775ada --- /dev/null +++ b/backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQuery.cs @@ -0,0 +1,7 @@ +using Logitar.Master.Contracts.Projects; +using Logitar.Master.Contracts.Search; +using MediatR; + +namespace Logitar.Master.Application.Projects.Queries; + +public record SearchProjectsQuery(SearchProjectsPayload Payload) : IRequest>; diff --git a/backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQueryHandler.cs b/backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQueryHandler.cs new file mode 100644 index 0000000..7e575ee --- /dev/null +++ b/backend/src/Logitar.Master.Application/Projects/Queries/SearchProjectsQueryHandler.cs @@ -0,0 +1,20 @@ +using Logitar.Master.Contracts.Projects; +using Logitar.Master.Contracts.Search; +using MediatR; + +namespace Logitar.Master.Application.Projects.Queries; + +internal class SearchProjectsQueryHandler : IRequestHandler> +{ + private readonly IProjectQuerier _projectQuerier; + + public SearchProjectsQueryHandler(IProjectQuerier projectQuerier) + { + _projectQuerier = projectQuerier; + } + + public async Task> Handle(SearchProjectsQuery query, CancellationToken cancellationToken) + { + return await _projectQuerier.SearchAsync(query.Payload, cancellationToken); + } +} diff --git a/backend/src/Logitar.Master.Contracts/Projects/ProjectSort.cs b/backend/src/Logitar.Master.Contracts/Projects/ProjectSort.cs new file mode 100644 index 0000000..1475f8c --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Projects/ProjectSort.cs @@ -0,0 +1,8 @@ +namespace Logitar.Master.Contracts.Projects; + +public enum ProjectSort +{ + DisplayName, + UniqueKey, + UpdatedOn +} diff --git a/backend/src/Logitar.Master.Contracts/Projects/ProjectSortOption.cs b/backend/src/Logitar.Master.Contracts/Projects/ProjectSortOption.cs new file mode 100644 index 0000000..4807923 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Projects/ProjectSortOption.cs @@ -0,0 +1,19 @@ +using Logitar.Master.Contracts.Search; + +namespace Logitar.Master.Contracts.Projects; + +public record ProjectSortOption : SortOption +{ + public new ProjectSort Field + { + get => Enum.Parse(base.Field); + set => base.Field = value.ToString(); + } + public ProjectSortOption() : this(ProjectSort.UpdatedOn, isDescending: true) + { + } + + public ProjectSortOption(ProjectSort field, bool isDescending = false) : base(field.ToString(), isDescending) + { + } +} diff --git a/backend/src/Logitar.Master.Contracts/Projects/SearchProjectsPayload.cs b/backend/src/Logitar.Master.Contracts/Projects/SearchProjectsPayload.cs new file mode 100644 index 0000000..714109f --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Projects/SearchProjectsPayload.cs @@ -0,0 +1,8 @@ +using Logitar.Master.Contracts.Search; + +namespace Logitar.Master.Contracts.Projects; + +public record SearchProjectsPayload : SearchPayload +{ + public new List? Sort { get; set; } +} diff --git a/backend/src/Logitar.Master.Contracts/Search/SearchOperator.cs b/backend/src/Logitar.Master.Contracts/Search/SearchOperator.cs new file mode 100644 index 0000000..771cb25 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Search/SearchOperator.cs @@ -0,0 +1,7 @@ +namespace Logitar.Master.Contracts.Search; + +public enum SearchOperator +{ + And = 0, + Or = 1 +} diff --git a/backend/src/Logitar.Master.Contracts/Search/SearchPayload.cs b/backend/src/Logitar.Master.Contracts/Search/SearchPayload.cs new file mode 100644 index 0000000..7b1b4f6 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Search/SearchPayload.cs @@ -0,0 +1,12 @@ +namespace Logitar.Master.Contracts.Search; + +public record SearchPayload +{ + public List? Ids { get; set; } + public TextSearch? Search { get; set; } + + public List? Sort { get; set; } + + public int? Skip { get; set; } + public int? Limit { get; set; } +} diff --git a/backend/src/Logitar.Master.Contracts/Search/SearchResults.cs b/backend/src/Logitar.Master.Contracts/Search/SearchResults.cs new file mode 100644 index 0000000..ae4f7d6 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Search/SearchResults.cs @@ -0,0 +1,28 @@ +namespace Logitar.Master.Contracts.Search; + +public record SearchResults +{ + public List Items { get; set; } + public long Total { get; set; } + + public SearchResults() + { + Items = []; + } + + public SearchResults(IEnumerable items) : this() + { + Items.AddRange(items); + } + + public SearchResults(long total) : this() + { + Total = total; + } + + public SearchResults(IEnumerable items, long total) : this() + { + Items.AddRange(items); + Total = total; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Search/SearchTerm.cs b/backend/src/Logitar.Master.Contracts/Search/SearchTerm.cs new file mode 100644 index 0000000..daa6e4c --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Search/SearchTerm.cs @@ -0,0 +1,15 @@ +namespace Logitar.Master.Contracts.Search; + +public record SearchTerm +{ + public string Value { get; set; } + + public SearchTerm() : this(string.Empty) + { + } + + public SearchTerm(string value) + { + Value = value; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Search/SortOption.cs b/backend/src/Logitar.Master.Contracts/Search/SortOption.cs new file mode 100644 index 0000000..51bdc44 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Search/SortOption.cs @@ -0,0 +1,17 @@ +namespace Logitar.Master.Contracts.Search; + +public record SortOption +{ + public string Field { get; set; } + public bool IsDescending { get; set; } + + public SortOption() : this(string.Empty) + { + } + + public SortOption(string field, bool isDescending = false) + { + Field = field; + IsDescending = isDescending; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Search/TextSearch.cs b/backend/src/Logitar.Master.Contracts/Search/TextSearch.cs new file mode 100644 index 0000000..05c595a --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Search/TextSearch.cs @@ -0,0 +1,18 @@ +namespace Logitar.Master.Contracts.Search; + +public record TextSearch +{ + public List Terms { get; set; } + public SearchOperator Operator { get; set; } + + public TextSearch() + { + Terms = []; + } + + public TextSearch(IEnumerable terms, SearchOperator @operator = SearchOperator.And) : this() + { + Terms.AddRange(terms); + Operator = @operator; + } +} diff --git a/backend/src/Logitar.Master.Domain/Projects/IProjectRepository.cs b/backend/src/Logitar.Master.Domain/Projects/IProjectRepository.cs index 9611993..6b181db 100644 --- a/backend/src/Logitar.Master.Domain/Projects/IProjectRepository.cs +++ b/backend/src/Logitar.Master.Domain/Projects/IProjectRepository.cs @@ -2,6 +2,7 @@ public interface IProjectRepository { + Task> LoadAsync(CancellationToken cancellationToken = default); Task LoadAsync(Guid id, CancellationToken cancellationToken = default); Task LoadAsync(ProjectId id, CancellationToken cancellationToken = default); Task LoadAsync(ProjectId id, long? version, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Master.EntityFrameworkCore.SqlServer/SqlServerHelper.cs b/backend/src/Logitar.Master.EntityFrameworkCore.SqlServer/SqlServerHelper.cs index feecadf..daf1837 100644 --- a/backend/src/Logitar.Master.EntityFrameworkCore.SqlServer/SqlServerHelper.cs +++ b/backend/src/Logitar.Master.EntityFrameworkCore.SqlServer/SqlServerHelper.cs @@ -3,7 +3,7 @@ namespace Logitar.Master.EntityFrameworkCore.SqlServer; -internal class SqlServerHelper : ISqlHelper +internal class SqlServerHelper : SqlHelper, ISqlHelper { - public IQueryBuilder QueryFrom(TableId table) => SqlServerQueryBuilder.From(table); + public override IQueryBuilder QueryFrom(TableId table) => SqlServerQueryBuilder.From(table); } diff --git a/backend/src/Logitar.Master.EntityFrameworkCore/ISqlHelper.cs b/backend/src/Logitar.Master.EntityFrameworkCore/ISqlHelper.cs index 8bf346c..69e2fab 100644 --- a/backend/src/Logitar.Master.EntityFrameworkCore/ISqlHelper.cs +++ b/backend/src/Logitar.Master.EntityFrameworkCore/ISqlHelper.cs @@ -1,8 +1,10 @@ using Logitar.Data; +using Logitar.Master.Contracts.Search; namespace Logitar.Master.EntityFrameworkCore; public interface ISqlHelper { + void ApplyTextSearch(IQueryBuilder query, TextSearch? search, params ColumnId[] columns); IQueryBuilder QueryFrom(TableId table); } diff --git a/backend/src/Logitar.Master.EntityFrameworkCore/Logitar.Master.EntityFrameworkCore.csproj b/backend/src/Logitar.Master.EntityFrameworkCore/Logitar.Master.EntityFrameworkCore.csproj index 23c6b98..12c68fb 100644 --- a/backend/src/Logitar.Master.EntityFrameworkCore/Logitar.Master.EntityFrameworkCore.csproj +++ b/backend/src/Logitar.Master.EntityFrameworkCore/Logitar.Master.EntityFrameworkCore.csproj @@ -19,6 +19,7 @@ + diff --git a/backend/src/Logitar.Master.EntityFrameworkCore/Queriers/ProjectQuerier.cs b/backend/src/Logitar.Master.EntityFrameworkCore/Queriers/ProjectQuerier.cs index 0657a9c..7fd2b5d 100644 --- a/backend/src/Logitar.Master.EntityFrameworkCore/Queriers/ProjectQuerier.cs +++ b/backend/src/Logitar.Master.EntityFrameworkCore/Queriers/ProjectQuerier.cs @@ -1,7 +1,9 @@ -using Logitar.EventSourcing; +using Logitar.Data; +using Logitar.EventSourcing; using Logitar.Master.Application.Projects; using Logitar.Master.Contracts.Actors; using Logitar.Master.Contracts.Projects; +using Logitar.Master.Contracts.Search; using Logitar.Master.Domain.Projects; using Logitar.Master.EntityFrameworkCore.Actors; using Logitar.Master.EntityFrameworkCore.Entities; @@ -13,11 +15,13 @@ internal class ProjectQuerier : IProjectQuerier { private readonly IActorService _actorService; private readonly MasterContext _context; + private readonly ISqlHelper _sqlHelper; - public ProjectQuerier(IActorService actorService, MasterContext context) + public ProjectQuerier(IActorService actorService, MasterContext context, ISqlHelper sqlHelper) { _actorService = actorService; _context = context; + _sqlHelper = sqlHelper; } public async Task ReadAsync(ProjectAggregate project, CancellationToken cancellationToken) @@ -49,6 +53,50 @@ public async Task ReadAsync(ProjectAggregate project, CancellationToken return project == null ? null : await MapAsync(project, cancellationToken); } + public async Task> SearchAsync(SearchProjectsPayload payload, CancellationToken cancellationToken) + { + IQueryBuilder builder = _sqlHelper.QueryFrom(MasterDb.Projects.Table).SelectAll(MasterDb.Projects.Table) + .ApplyIdFilter(MasterDb.Projects.AggregateId, payload.Ids); + _sqlHelper.ApplyTextSearch(builder, payload.Search, MasterDb.Projects.UniqueKey, MasterDb.Projects.DisplayName); + + IQueryable query = _context.Projects.FromQuery(builder).AsNoTracking(); + long total = await query.LongCountAsync(cancellationToken); + + IOrderedQueryable? ordered = null; + if (payload.Sort != null && payload.Sort.Count > 0) + { + foreach (ProjectSortOption sort in payload.Sort) + { + switch (sort.Field) + { + case ProjectSort.DisplayName: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.DisplayName) : query.OrderBy(x => x.DisplayName)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.DisplayName) : ordered.ThenByDescending(x => x.DisplayName)); + break; + case ProjectSort.UniqueKey: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.UniqueKey) : query.OrderBy(x => x.UniqueKey)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.UniqueKey) : ordered.ThenByDescending(x => x.UniqueKey)); + break; + case ProjectSort.UpdatedOn: + ordered = (ordered == null) + ? (sort.IsDescending ? query.OrderByDescending(x => x.UpdatedOn) : query.OrderBy(x => x.UpdatedOn)) + : (sort.IsDescending ? ordered.ThenByDescending(x => x.UpdatedOn) : ordered.ThenByDescending(x => x.UpdatedOn)); + break; + } + } + } + query = ordered ?? query; + + query = query.ApplyPaging(payload); + + ProjectEntity[] projects = await query.ToArrayAsync(cancellationToken); + IEnumerable items = await MapAsync(projects, cancellationToken); + + return new SearchResults(items, total); + } + private async Task MapAsync(ProjectEntity project, CancellationToken cancellationToken) { return (await MapAsync([project], cancellationToken)).Single(); diff --git a/backend/src/Logitar.Master.EntityFrameworkCore/QueryingExtensions.cs b/backend/src/Logitar.Master.EntityFrameworkCore/QueryingExtensions.cs index 4ffbae0..f7935d4 100644 --- a/backend/src/Logitar.Master.EntityFrameworkCore/QueryingExtensions.cs +++ b/backend/src/Logitar.Master.EntityFrameworkCore/QueryingExtensions.cs @@ -1,10 +1,38 @@ using Logitar.Data; +using Logitar.EventSourcing; +using Logitar.Master.Contracts.Search; using Microsoft.EntityFrameworkCore; namespace Logitar.Master.EntityFrameworkCore; internal static class QueryingExtensions { + public static IQueryBuilder ApplyIdFilter(this IQueryBuilder query, ColumnId column, IEnumerable? ids) + { + if (ids != null && ids.Any()) + { + string[] aggregateIds = ids.Distinct().Select(id => new AggregateId(id).Value).ToArray(); + query.Where(column, Operators.IsIn(aggregateIds)); + } + + return query; + } + + public static IQueryable ApplyPaging(this IQueryable query, SearchPayload payload) + { + if (payload.Skip > 0) + { + query = query.Skip(payload.Skip.Value); + } + + if (payload.Limit > 0) + { + query = query.Take(payload.Limit.Value); + } + + return query; + } + public static IQueryable FromQuery(this DbSet entities, IQueryBuilder query) where T : class { return entities.FromQuery(query.Build()); diff --git a/backend/src/Logitar.Master.EntityFrameworkCore/Repositories/ProjectRepository.cs b/backend/src/Logitar.Master.EntityFrameworkCore/Repositories/ProjectRepository.cs index 0e3fbb2..c1817b8 100644 --- a/backend/src/Logitar.Master.EntityFrameworkCore/Repositories/ProjectRepository.cs +++ b/backend/src/Logitar.Master.EntityFrameworkCore/Repositories/ProjectRepository.cs @@ -19,6 +19,11 @@ public ProjectRepository(IEventBus eventBus, EventContext eventContext, IEventSe _sqlHelper = sqlHelper; } + public async Task> LoadAsync(CancellationToken cancellationToken) + { + return await base.LoadAsync(cancellationToken); + } + public async Task LoadAsync(Guid id, CancellationToken cancellationToken) { return await base.LoadAsync(new AggregateId(id), cancellationToken); diff --git a/backend/src/Logitar.Master.EntityFrameworkCore/SqlHelper.cs b/backend/src/Logitar.Master.EntityFrameworkCore/SqlHelper.cs new file mode 100644 index 0000000..f3b9588 --- /dev/null +++ b/backend/src/Logitar.Master.EntityFrameworkCore/SqlHelper.cs @@ -0,0 +1,45 @@ +using Logitar.Data; +using Logitar.Master.Contracts.Search; + +namespace Logitar.Master.EntityFrameworkCore; + +public abstract class SqlHelper : ISqlHelper +{ + public virtual void ApplyTextSearch(IQueryBuilder query, TextSearch? search, params ColumnId[] columns) + { + if (search != null && search.Terms.Count > 0 && columns.Length > 0) + { + List conditions = new(capacity: search.Terms.Count); + HashSet patterns = new(capacity: search.Terms.Count); + foreach (SearchTerm term in search.Terms) + { + if (!string.IsNullOrWhiteSpace(term.Value)) + { + string pattern = term.Value.Trim(); + if (patterns.Add(pattern)) + { + conditions.Add(columns.Length == 1 + ? new OperatorCondition(columns[0], CreateOperator(pattern)) + : new OrCondition(columns.Select(column => new OperatorCondition(column, CreateOperator(pattern))).ToArray())); + } + } + } + + if (conditions.Count > 0) + { + switch (search.Operator) + { + case SearchOperator.And: + query.WhereAnd([.. conditions]); + break; + case SearchOperator.Or: + query.WhereOr([.. conditions]); + break; + } + } + } + } + public virtual ConditionalOperator CreateOperator(string pattern) => Operators.IsLike(pattern); + + public abstract IQueryBuilder QueryFrom(TableId table); +} diff --git a/backend/tests/Logitar.Master.IntegrationTests/Application/Projects/Queries/SearchProjectQueryTests.cs b/backend/tests/Logitar.Master.IntegrationTests/Application/Projects/Queries/SearchProjectQueryTests.cs new file mode 100644 index 0000000..f8193b0 --- /dev/null +++ b/backend/tests/Logitar.Master.IntegrationTests/Application/Projects/Queries/SearchProjectQueryTests.cs @@ -0,0 +1,58 @@ +using Logitar.Master.Contracts.Projects; +using Logitar.Master.Contracts.Search; +using Logitar.Master.Domain.Projects; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Master.Application.Projects.Queries; + +[Trait(Traits.Category, Categories.Integration)] +public class SearchProjectQueryTests : IntegrationTests +{ + private readonly IProjectRepository _projectRepository; + + public SearchProjectQueryTests() : base() + { + _projectRepository = ServiceProvider.GetRequiredService(); + } + + [Fact(DisplayName = "It should return empty results when none match.")] + public async Task It_should_return_empty_results_when_none_match() + { + SearchProjectsPayload payload = new(); + SearchProjectsQuery query = new(payload); + SearchResults results = await Mediator.Send(query); + + Assert.Empty(results.Items); + Assert.Equal(0, results.Total); + } + + [Fact(DisplayName = "It should return the correct search results.")] + public async Task It_should_return_the_correct_search_results() + { + ProjectAggregate oubliette = new(new UniqueKeyUnit("OUBLIETTE")); + ProjectAggregate skillCraft = new(new UniqueKeyUnit("SKILLCRAFT")); + ProjectAggregate portal = new(new UniqueKeyUnit("PORTAL")); + ProjectAggregate master = new(new UniqueKeyUnit("MASTER")); + await _projectRepository.SaveAsync([oubliette, skillCraft, portal, master]); + + List ids = (await _projectRepository.LoadAsync()).Select(project => project.Id.ToGuid()).ToList(); + ids.Remove(oubliette.Id.ToGuid()); + ids.Add(Guid.Empty); + + SearchProjectsPayload payload = new() + { + Ids = ids, + Search = new TextSearch([new SearchTerm("%o%"), new SearchTerm("%M%")], SearchOperator.Or), + Sort = [new ProjectSortOption(ProjectSort.UniqueKey, isDescending: true)], + Skip = 1, + Limit = 1 + }; + SearchProjectsQuery query = new(payload); + + SearchResults results = await Mediator.Send(query); + Assert.Equal(2, results.Total); + + Project project = Assert.Single(results.Items); + Assert.Equal(master.Id.ToGuid(), project.Id); + } +}