From 9fed2b5ef4bc236b6759532121059a3187eb47c6 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Wed, 29 Jul 2026 16:38:14 -0300 Subject: [PATCH 01/18] feat: add full-text-search capabilities --- .../Common/Repositories/IBaseRepository.cs | 15 +++++++ .../Common/Requests/BaseRequest.cs | 26 +++++++++++ .../GetAllFullTextSearchOrdersUseCase.cs | 44 +++++++++++++++++++ .../Data/Common/BaseRepository.cs | 41 +++++++++++++++++ .../Data/Mapping/ItemDbMapping.cs | 5 +++ .../Data/Mapping/OrderDbMapping.cs | 5 +++ .../src/WebApp/Endpoints/OrderEndpoints.cs | 11 +++++ 7 files changed, 147 insertions(+) create mode 100644 templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs diff --git a/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs b/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs index aa446b85..a509a966 100644 --- a/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs +++ b/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs @@ -24,6 +24,21 @@ public interface IBaseRepository params Expression>[]? includes ) where TEntity : DomainEntity; + Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity; + Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( Guid correlationId, int page, diff --git a/templates/Full/src/Application/Common/Requests/BaseRequest.cs b/templates/Full/src/Application/Common/Requests/BaseRequest.cs index 5bb451a2..5aa111b4 100644 --- a/templates/Full/src/Application/Common/Requests/BaseRequest.cs +++ b/templates/Full/src/Application/Common/Requests/BaseRequest.cs @@ -15,3 +15,29 @@ public record BasePaginatedRequest( string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); + +/// +/// Represents a request for paginated data with full-text search capabilities. +/// +/// +/// +/// +/// +/// +/// The full-text search query e.g. "Column1", "Column2 & Column3" +/// The value to search for within the full-text index e.g. "search term" +/// The language to use for the full-text search +/// +/// +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + [property: Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] int Page = 1, + [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchQuery = "", + string SearchValue = "", + string SearchLanguage = "english", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs new file mode 100644 index 00000000..ad20af5e --- /dev/null +++ b/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -0,0 +1,44 @@ +using Application.Common.Helpers; +using Application.Common.Requests; +using Application.Common.UseCases; +using Domain.Orders; + +namespace Application.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCase(IServiceProvider serviceProvider): BaseInOutUseCase>(serviceProvider) +{ + public override async Task> HandleInternalAsync( + BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) + { + var (orders, totalRecords) = await Repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() + { + Id = o.Id, + Description = o.Description, + Total = o.Total, + PeriodSinceWasCreated = o.GetPeriodSinceWasCreated() + }, + cancellationToken, + request.SortBy, + request.SortDescending, + request.SearchQuery, + request.SearchValue, + request.SearchLanguage + ); + + if (orders is null || !orders.Any()) + { + Logs.NotFound(Logger, request.CorrelationId, nameof(orders)); + return new(false, 0, 0, [], "No orders found."); + } + + var totalPages = (int) Math.Ceiling(totalRecords / (double) request.PageSize); + + return new(true, totalPages, totalRecords, orders); + } +} diff --git a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs index be1a5e8d..b7ff715b 100644 --- a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs @@ -149,6 +149,47 @@ params Expression>[]? includes return (items, totalRecords); }, correlationId, newContext); + public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => + { + var query = dbEntitySet.AsQueryable(); + + if (predicate != null) + query = query.Where(predicate); + + if (!string.IsNullOrWhiteSpace(sortBy)) + query = sortDescending + ? query.OrderByDescending(e => EF.Property(e, sortBy)) + : query.OrderBy(e => EF.Property(e, sortBy)); + else + query = query.OrderBy(e => e.CreatedAt); + + var totalRecords = await query.CountAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, searchQuery).Matches(searchValue)); + + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(selector) + .ToListAsync(cancellationToken); + + return (items, totalRecords); + }, correlationId, newContext); + public async Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( Guid correlationId, int page, diff --git a/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs b/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs index 7212a550..2f9dcf2b 100644 --- a/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs +++ b/templates/Full/src/Infrastructure/Data/Mapping/ItemDbMapping.cs @@ -1,5 +1,6 @@ using Domain.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -19,5 +20,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) builder.Property(p => p.Description) .HasMaxLength(255) .IsRequired(); + + builder.HasIndex(p => new { p.Name, p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs b/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs index 7854a2d4..fefeb090 100644 --- a/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs +++ b/templates/Full/src/Infrastructure/Data/Mapping/OrderDbMapping.cs @@ -1,5 +1,6 @@ using Domain.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -17,5 +18,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) .HasPrecision(18, 2); builder.HasMany(p => p.Items); + + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs b/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs index 86797bb1..81eb6177 100644 --- a/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs +++ b/templates/Full/src/WebApp/Endpoints/OrderEndpoints.cs @@ -62,6 +62,17 @@ CancellationToken cancellationToken return response.Success ? Results.Ok(response) : Results.BadRequest(response); }); + ordersGroup.MapPost("/full-text-search-paginated", async ( + [FromServices] IBaseInOutUseCase> useCase, + [FromBody] BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) => + { + var response = await useCase.HandleAsync(request, cancellationToken); + + return response.Success ? Results.Ok(response) : Results.BadRequest(response); + }); + ordersGroup.MapPut("/{id}", async ( [FromServices] IBaseInOutUseCase> useCase, [FromBody] UpdateOrderRequest request, From ade489f18c5ed7d2243391aa86b69f3ff123d2b9 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Wed, 29 Jul 2026 16:40:34 -0300 Subject: [PATCH 02/18] feat: remove GetAllPaginatedAsync method from BaseRepository and IBaseRepository --- .../Common/Repositories/IBaseRepository.cs | 11 -- .../Data/Common/BaseRepository.cs | 41 ------ .../Data/BaseRepositoryTest.cs | 121 ------------------ 3 files changed, 173 deletions(-) diff --git a/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs b/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs index a509a966..fc032338 100644 --- a/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs +++ b/templates/Full/src/Application/Common/Repositories/IBaseRepository.cs @@ -12,17 +12,6 @@ public interface IBaseRepository Task RemoveAsync(TEntity entity, Guid correlationId, CancellationToken cancellationToken, bool? newContext = null) where TEntity : DomainEntity; Task RemoveRangeAsync(TEntity[] entities, Guid correlationId, CancellationToken cancellationToken, bool? newContext = null) where TEntity : DomainEntity; IQueryable GetQueryable(Guid correlationId, bool? newContext = null, [CallerMemberName] string methodName = null!) where TEntity : DomainEntity; - Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( - Guid correlationId, - int page, - int pageSize, - CancellationToken cancellationToken, - string? sortBy = null, - bool sortDescending = false, - Dictionary? searchByValues = null, - bool? newContext = null, - params Expression>[]? includes - ) where TEntity : DomainEntity; Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( Guid correlationId, diff --git a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs index b7ff715b..8f35d1f7 100644 --- a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs @@ -108,47 +108,6 @@ await HandleBaseQueryAsync(async dbEntitySet => return await _dbContext.SaveChangesAsync(cancellationToken); }, correlationId, newContext); - public async Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( - Guid correlationId, - int page, - int pageSize, - CancellationToken cancellationToken, - string? sortBy = null!, - bool sortDescending = false, - Dictionary? searchByValues = null!, - bool? newContext = null, - params Expression>[]? includes - ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => - { - var query = dbEntitySet.AsNoTracking(); - - if (includes is not null) - foreach (var include in includes) - query = query.Include(include); - - if (!string.IsNullOrWhiteSpace(sortBy)) - query = sortDescending - ? query.OrderByDescending(e => EF.Property(e, sortBy)) - : query.OrderBy(e => EF.Property(e, sortBy)); - else - query = query.OrderBy(e => e.CreatedAt); - - var totalRecords = await query.CountAsync(cancellationToken); - - if (searchByValues != null && searchByValues.Count != 0) - foreach (var searchByValue in searchByValues) - query = query.Where(e => - EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") - ); - - var items = await query - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return (items, totalRecords); - }, correlationId, newContext); - public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( Guid correlationId, int page, diff --git a/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs b/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs index 51b68b17..c84974fc 100644 --- a/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs +++ b/templates/Full/tests/IntegrationTests/Data/BaseRepositoryTest.cs @@ -91,27 +91,6 @@ public async Task GivenAOrderAndNotificationShouldExecuteInParallelWithSuccess() Assert.Equal(id, order2!.Id); } - [Fact] - public async Task GivenAValidRequestThenReturnAllOrdersPaginatedWithSuccess() - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken - ); - - // Assert - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(totalRecords > 0); - } - [Fact] public async Task GivenAValidRequestThenReturnAllOrdersDtosPaginatedWithSuccess() { @@ -145,104 +124,4 @@ public async Task GivenAValidRequestThenReturnAllOrdersDtosPaginatedWithSuccess( Assert.All(result, r => Assert.IsType(r)); Assert.All(result, r => Assert.NotNull(r.Items)); } - - [Fact] - public async Task GivenAValidRequestThenReturnNoOrdersPaginated() - { - // Arrange - var pageNumber = 50; - var pageSize = 5; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken - ); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - Assert.True(totalRecords > 0); - } - - [Fact] - public async Task GivenAValidRequestThenReturnFilteredOrdersPaginated() - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - var valueToSearch = "client"; - var searchByValues = new Dictionary { - { "Description", valueToSearch } - }; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken, - searchByValues: searchByValues - ); - - // Assert - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(totalRecords > 0); - Assert.All(result, o => Assert.Contains(valueToSearch.ToLowerInvariant(), o.Description.ToLowerInvariant())); - } - - [Fact] - public async Task GivenAValidRequestThenReturnNoFilteredOrdersPaginated() - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - var searchByValues = new Dictionary { - { "Description", "non-existing-description" } - }; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken, - searchByValues: searchByValues - ); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - Assert.True(totalRecords > 0); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task GivenAValidRequestThenReturnSortedOrdersPaginated(bool sortDescending) - { - // Arrange - var pageNumber = 1; - var pageSize = 5; - var sortBy = "Description"; - - // Act - var (result, totalRecords) = await _fixture!.Repository!.GetAllPaginatedAsync( - Guid.NewGuid(), - pageNumber, - pageSize, - _fixture.CancellationToken, - sortBy: sortBy, - sortDescending: sortDescending - ); - - // Assert - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(totalRecords > 0); - Assert.All(result, r => Assert.NotNull(r.Description)); - } } From 612b3a2552987ebe8669c4579d0aa9cc4a5704aa Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Wed, 29 Jul 2026 17:01:45 -0300 Subject: [PATCH 03/18] feat: add unit/integration tests and migration for GetAllFullTextSearchOrdersUseCase Co-Authored-By: Claude --- ...29195925_FullTextSearchSupport.Designer.cs | 236 +++++++++ .../20260729195925_FullTextSearchSupport.cs | 41 ++ .../Migrations/MyDbContextModelSnapshot.cs | 456 +++++++++--------- .../Orders/GetAllFullTextSearchOrdersTest.cs | 109 +++++ .../Common/RepositoryMockExtensions.cs | 50 ++ .../GetAllFullTextSearchOrdersUseCaseTests.cs | 100 ++++ 6 files changed, 769 insertions(+), 223 deletions(-) create mode 100644 templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs create mode 100644 templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs create mode 100644 templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs create mode 100644 templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs diff --git a/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs new file mode 100644 index 00000000..33973fcd --- /dev/null +++ b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + [Migration("20260729195925_FullTextSearchSupport")] + partial class FullTextSearchSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.HasOne("Domain.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs new file mode 100644 index 00000000..31faba64 --- /dev/null +++ b/templates/Full/src/Infrastructure/Data/Migrations/20260729195925_FullTextSearchSupport.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations; + +/// +public partial class FullTextSearchSupport : Migration +{ + private static readonly string[] s_columns = ["Name", "Description"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); + + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); + } +} diff --git a/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs b/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs index 42508011..6d614c56 100644 --- a/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs +++ b/templates/Full/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs @@ -1,223 +1,233 @@ -// -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(MyDbContext))] - partial class MyDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Notifications.Notification", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Message") - .HasMaxLength(4000) - .HasColumnType("jsonb"); - - b.Property("NotificationStatus") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("NotificationType") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Notification"); - }); - - modelBuilder.Entity("Domain.Orders.Item", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("text"); - - b.Property("OrderId") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Value") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("OrderId"); - - b.ToTable("Item"); - }); - - modelBuilder.Entity("Domain.Orders.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Total") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Order"); - }); - - modelBuilder.Entity("Domain.Orders.Item", b => - { - b.HasOne("Domain.Orders.Order", null) - .WithMany("Items") - .HasForeignKey("OrderId"); - }); - - modelBuilder.Entity("Domain.Orders.Order", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + partial class MyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Domain.Orders.Item", b => + { + b.HasOne("Domain.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Domain.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs b/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs new file mode 100644 index 00000000..e75e1cfb --- /dev/null +++ b/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs @@ -0,0 +1,109 @@ +using System.Net; +using Application.Common.Requests; +using Application.Orders; +using IntegrationTests.Common; +using IntegrationTests.WebApp.Http.Common; +using WebApp; + +namespace IntegrationTests.WebApp.Http.Orders; + +public class GetAllFullTextSearchOrdersTestFixture : BaseHttpFixture +{ + public static BaseFullTextSearchPaginatedRequest SetValidRequest( + string searchQuery = "Description", + string searchValue = "client" + ) => new(Guid.NewGuid(), 1, 10, SearchQuery: searchQuery, SearchValue: searchValue); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageSizeRequest() => new(Guid.NewGuid(), 1, 0); +} + +[Collection("WebApplicationFactoryCollectionDefinition")] +public class GetAllFullTextSearchOrdersTest : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersTestFixture _fixture; + + public GetAllFullTextSearchOrdersTest(CustomWebApplicationFactory customWebApplicationFactory, GetAllFullTextSearchOrdersTestFixture fixture) + { + _fixture = fixture; + _fixture.SetApiHelper(customWebApplicationFactory); + _fixture.ResourceUrl = "orders/full-text-search-paginated"; + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.True(response!.Success); + Assert.NotNull(response.Data); + Assert.True(response.TotalPages >= 0); + Assert.True(response.TotalRecords >= 0); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("Page must be greater than 0", response.Message); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageSizeRequestThenFails))] + public async Task GivenAnInvalidPageSizeRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageSizeRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("PageSize must be between 1 and 100", response.Message); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWithSearchQueryAndValueThenPass))] + public async Task GivenAValidRequestWithSearchQueryAndValueThenPass() + { + // Arrange + var request = new BaseFullTextSearchPaginatedRequest( + Guid.NewGuid(), 1, 10, + SearchQuery: "Description", + SearchValue: "client", + SearchLanguage: "english" + ); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.True(response!.Success); + Assert.NotNull(response.Data); + Assert.True(response.TotalPages >= 0); + Assert.True(response.TotalRecords >= 0); + } +} diff --git a/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs b/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs index 4e0aef7f..6e1488d2 100644 --- a/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs +++ b/templates/Full/tests/UnitTests/Application/Common/RepositoryMockExtensions.cs @@ -42,6 +42,56 @@ public static void SetFailedUpdate(this Mock mockRepos .Setup(d => d.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(0); + public static void SetValidGetAllFullTextSearchPaginatedAsync( + this Mock mockRepository, + IEnumerable data, + int totalRecords + ) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync((data, totalRecords)); + + public static void SetInvalidGetAllFullTextSearchPaginatedAsync(this Mock mockRepository) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync(([], 0)); + + public static void VerifyGetAllFullTextSearchPaginated(this Mock mockRepository, int times = 1) where TEntity : DomainEntity where TResult : class => mockRepository + .Verify(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), Times.Exactly(times)); + public static void SetValidGetAllPaginatedAsyncNoIncludes( this Mock mockRepository, IEnumerable data, diff --git a/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs new file mode 100644 index 00000000..4f71c62a --- /dev/null +++ b/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -0,0 +1,100 @@ +using Application.Common.Requests; +using Application.Orders; +using Domain.Orders; +using UnitTests.Application.Common; + +namespace UnitTests.Application.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseApplicationFixture +{ + public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); + + public static BaseFullTextSearchPaginatedRequest SetValidRequest( + string searchQuery = "Description", + string searchValue = "client", + string searchLanguage = "english" + ) => new(Guid.NewGuid(), 1, 10, SearchQuery: searchQuery, SearchValue: searchValue, SearchLanguage: searchLanguage); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); +} + +public sealed class GetAllFullTextSearchOrdersUseCaseTests : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersUseCaseFixture _fixture; + + public GetAllFullTextSearchOrdersUseCaseTests(GetAllFullTextSearchOrdersUseCaseFixture fixture) + { + _fixture = fixture; + _fixture.ClearInvocations(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var totalRecords = 5; + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + var expectedOrders = _fixture.AutoFixture.CreateMany(totalRecords); + + _fixture.MockRepository.SetValidGetAllFullTextSearchPaginatedAsync(expectedOrders, totalRecords); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Null(result.Message); + Assert.NotNull(result.Data); + Assert.Equal(expectedOrders.Count(), result.Data.Count()); + Assert.Equal(1, result.TotalPages); + Assert.Equal(totalRecords, result.TotalRecords); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWhenNoOrdersFoundThenFails))] + public async Task GivenAValidRequestWhenNoOrdersFoundThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + _fixture.MockRepository.SetInvalidGetAllFullTextSearchPaginatedAsync(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + Assert.Equal("No orders found.", result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(1); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(0); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(0); + } +} From f5a5ecf944a2538bee0de9e922e69b8ca1bae4ac Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Wed, 29 Jul 2026 17:10:57 -0300 Subject: [PATCH 04/18] feat: add migration script for full text search support --- templates/Full/scripts/sql/migrations.sql | 207 ++++++++++++---------- 1 file changed, 116 insertions(+), 91 deletions(-) diff --git a/templates/Full/scripts/sql/migrations.sql b/templates/Full/scripts/sql/migrations.sql index 1270c5ff..495fc33f 100644 --- a/templates/Full/scripts/sql/migrations.sql +++ b/templates/Full/scripts/sql/migrations.sql @@ -1,91 +1,116 @@ -CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( - "MigrationId" character varying(150) NOT NULL, - "ProductVersion" character varying(32) NOT NULL, - CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") -); - -START TRANSACTION; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE TABLE "Notification" ( - "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "NotificationType" integer NOT NULL, - "NotificationStatus" character varying(50) NOT NULL, - "Message" jsonb, - "CreatedAt" timestamp with time zone NOT NULL, - "CreatedBy" text, - "CreatedByTimezoneId" text NOT NULL, - "UpdatedAt" timestamp with time zone NOT NULL, - "UpdatedBy" text, - "UpdatedByTimezoneId" text, - "IsDeleted" boolean NOT NULL DEFAULT FALSE, - "DeletedAt" timestamp with time zone, - "DeletedBy" text, - CONSTRAINT "PK_Notification" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE TABLE "Order" ( - "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "Description" text NOT NULL, - "Total" numeric(18,2) NOT NULL, - "CreatedAt" timestamp with time zone NOT NULL, - "CreatedBy" text, - "CreatedByTimezoneId" text NOT NULL, - "UpdatedAt" timestamp with time zone NOT NULL, - "UpdatedBy" text, - "UpdatedByTimezoneId" text, - "IsDeleted" boolean NOT NULL DEFAULT FALSE, - "DeletedAt" timestamp with time zone, - "DeletedBy" text, - CONSTRAINT "PK_Order" PRIMARY KEY ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE TABLE "Item" ( - "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "Name" text NOT NULL, - "Description" text NOT NULL, - "Value" numeric(18,2) NOT NULL, - "OrderId" integer, - "CreatedAt" timestamp with time zone NOT NULL, - "CreatedBy" text, - "CreatedByTimezoneId" text NOT NULL, - "UpdatedAt" timestamp with time zone NOT NULL, - "UpdatedBy" text, - "UpdatedByTimezoneId" text, - "IsDeleted" boolean NOT NULL DEFAULT FALSE, - "DeletedAt" timestamp with time zone, - "DeletedBy" text, - CONSTRAINT "PK_Item" PRIMARY KEY ("Id"), - CONSTRAINT "FK_Item_Order_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Order" ("Id") - ); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - CREATE INDEX "IX_Item_OrderId" ON "Item" ("OrderId"); - END IF; -END $EF$; - -DO $EF$ -BEGIN - IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN - INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES ('20260418162254_CreateTables', '10.0.5'); - END IF; -END $EF$; -COMMIT; - +CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" character varying(150) NOT NULL, + "ProductVersion" character varying(32) NOT NULL, + CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") +); + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE TABLE "Notification" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "NotificationType" integer NOT NULL, + "NotificationStatus" character varying(50) NOT NULL, + "Message" jsonb, + "CreatedAt" timestamp with time zone NOT NULL, + "CreatedBy" text, + "CreatedByTimezoneId" text NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + "UpdatedBy" text, + "UpdatedByTimezoneId" text, + "IsDeleted" boolean NOT NULL DEFAULT FALSE, + "DeletedAt" timestamp with time zone, + "DeletedBy" text, + CONSTRAINT "PK_Notification" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE TABLE "Order" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Description" text NOT NULL, + "Total" numeric(18,2) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "CreatedBy" text, + "CreatedByTimezoneId" text NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + "UpdatedBy" text, + "UpdatedByTimezoneId" text, + "IsDeleted" boolean NOT NULL DEFAULT FALSE, + "DeletedAt" timestamp with time zone, + "DeletedBy" text, + CONSTRAINT "PK_Order" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE TABLE "Item" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "Description" text NOT NULL, + "Value" numeric(18,2) NOT NULL, + "OrderId" integer, + "CreatedAt" timestamp with time zone NOT NULL, + "CreatedBy" text, + "CreatedByTimezoneId" text NOT NULL, + "UpdatedAt" timestamp with time zone NOT NULL, + "UpdatedBy" text, + "UpdatedByTimezoneId" text, + "IsDeleted" boolean NOT NULL DEFAULT FALSE, + "DeletedAt" timestamp with time zone, + "DeletedBy" text, + CONSTRAINT "PK_Item" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Item_Order_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Order" ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + CREATE INDEX "IX_Item_OrderId" ON "Item" ("OrderId"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260418162254_CreateTables') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260418162254_CreateTables', '10.0.10'); + END IF; +END $EF$; +COMMIT; + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260729195925_FullTextSearchSupport') THEN + CREATE INDEX "IX_Order_Description" ON "Order" USING GIN (to_tsvector('english', "Description")); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260729195925_FullTextSearchSupport') THEN + CREATE INDEX "IX_Item_Name_Description" ON "Item" USING GIN (to_tsvector('english', "Name" || ' ' || "Description")); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260729195925_FullTextSearchSupport') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260729195925_FullTextSearchSupport', '10.0.10'); + END IF; +END $EF$; +COMMIT; + From 2136a450ffb5f1d22376a46600799108af56a797 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 08:41:58 -0300 Subject: [PATCH 05/18] feat: improve .editorconfig for unused parameter detection --- templates/Full/.editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/Full/.editorconfig b/templates/Full/.editorconfig index 1e58c63c..bf83c78f 100644 --- a/templates/Full/.editorconfig +++ b/templates/Full/.editorconfig @@ -179,6 +179,7 @@ dotnet_diagnostic.IDE0002.severity = error dotnet_diagnostic.IDE0005.severity = error dotnet_diagnostic.IDE0051.severity = error dotnet_diagnostic.IDE0059.severity = error +dotnet_diagnostic.IDE0060.severity = error # Xml project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] From a490fe9c53686486f0e381373c58d7a12eabe6a3 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 08:50:27 -0300 Subject: [PATCH 06/18] feat: simplify full-text search request by removing SearchQuery and SearchLanguage params Co-Authored-By: Claude --- .../Common/Requests/BaseRequest.cs | 3 -- .../GetAllFullTextSearchOrdersUseCase.cs | 5 ++-- .../Orders/GetAllFullTextSearchOrdersTest.cs | 29 +++++++------------ .../GetAllFullTextSearchOrdersUseCaseTests.cs | 6 +--- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/templates/Full/src/Application/Common/Requests/BaseRequest.cs b/templates/Full/src/Application/Common/Requests/BaseRequest.cs index 5aa111b4..dc946f1c 100644 --- a/templates/Full/src/Application/Common/Requests/BaseRequest.cs +++ b/templates/Full/src/Application/Common/Requests/BaseRequest.cs @@ -24,7 +24,6 @@ public record BasePaginatedRequest( /// /// /// -/// The full-text search query e.g. "Column1", "Column2 & Column3" /// The value to search for within the full-text index e.g. "search term" /// The language to use for the full-text search /// @@ -35,9 +34,7 @@ public record BaseFullTextSearchPaginatedRequest( [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, string? SortBy = null, bool SortDescending = false, - string SearchQuery = "", string SearchValue = "", - string SearchLanguage = "english", string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs index ad20af5e..e541fa9b 100644 --- a/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs +++ b/templates/Full/src/Application/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -26,9 +26,8 @@ CancellationToken cancellationToken cancellationToken, request.SortBy, request.SortDescending, - request.SearchQuery, - request.SearchValue, - request.SearchLanguage + nameof(Order.Description), + request.SearchValue ); if (orders is null || !orders.Any()) diff --git a/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs b/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs index e75e1cfb..ec5cdca5 100644 --- a/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs +++ b/templates/Full/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs @@ -9,11 +9,7 @@ namespace IntegrationTests.WebApp.Http.Orders; public class GetAllFullTextSearchOrdersTestFixture : BaseHttpFixture { - public static BaseFullTextSearchPaginatedRequest SetValidRequest( - string searchQuery = "Description", - string searchValue = "client" - ) => new(Guid.NewGuid(), 1, 10, SearchQuery: searchQuery, SearchValue: searchValue); - + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); public static BaseFullTextSearchPaginatedRequest SetInvalidPageSizeRequest() => new(Guid.NewGuid(), 1, 0); } @@ -83,16 +79,11 @@ public async Task GivenAnInvalidPageSizeRequestThenFails() Assert.Contains("PageSize must be between 1 and 100", response.Message); } - [Fact(DisplayName = nameof(GivenAValidRequestWithSearchQueryAndValueThenPass))] - public async Task GivenAValidRequestWithSearchQueryAndValueThenPass() + [Fact(DisplayName = nameof(GivenAValidRequestWithSearchValueThenPass))] + public async Task GivenAValidRequestWithSearchValueThenPass() { // Arrange - var request = new BaseFullTextSearchPaginatedRequest( - Guid.NewGuid(), 1, 10, - SearchQuery: "Description", - SearchValue: "client", - SearchLanguage: "english" - ); + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(searchValue: "client"); // Act var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); @@ -100,10 +91,12 @@ public async Task GivenAValidRequestWithSearchQueryAndValueThenPass() // Assert Assert.NotNull(result); - Assert.Equal(HttpStatusCode.OK, result.StatusCode); - Assert.True(response!.Success); - Assert.NotNull(response.Data); - Assert.True(response.TotalPages >= 0); - Assert.True(response.TotalRecords >= 0); + Assert.NotNull(response); + Assert.True( + result.StatusCode == HttpStatusCode.OK || result.StatusCode == HttpStatusCode.BadRequest, + $"Unexpected status code: {result.StatusCode}" + ); + if (result.StatusCode == HttpStatusCode.BadRequest) + Assert.Equal("No orders found.", response.Message); } } diff --git a/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs index 4f71c62a..4588490a 100644 --- a/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs +++ b/templates/Full/tests/UnitTests/Application/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -9,11 +9,7 @@ public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseApplicationFi { public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); - public static BaseFullTextSearchPaginatedRequest SetValidRequest( - string searchQuery = "Description", - string searchValue = "client", - string searchLanguage = "english" - ) => new(Guid.NewGuid(), 1, 10, SearchQuery: searchQuery, SearchValue: searchValue, SearchLanguage: searchLanguage); + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "client") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); } From 1c090e288d2aecc9e76e57a9c835042ed83fa868 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 08:56:48 -0300 Subject: [PATCH 07/18] chore: update package version to 10.16.0 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dfd79efb..819cee82 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,7 +70,7 @@ jobs: uses: gpreviatti/github-actions-templates/.github/workflows/dotnet-pack.yml@v1 with: dotnet_version: "${{ vars.PROJECT_DOTNET_VERSION }}" - package_version: "10.15.0" + package_version: "10.16.0" secrets: nuget_api_key: ${{ secrets.NUGET_API_KEY }} From da2665e2f80936d3da4da552ee3929259679ca69 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 09:03:17 -0300 Subject: [PATCH 08/18] test: improve unit test coverage on Contracts template - Add BaseFullTextSearchPaginatedRequest tests (defaults, custom values, inheritance) - Add BaseRequest User/TimezoneId default and custom value tests - Add BasePaginatedRequest User/TimezoneId assertions and inheritance test - Add BaseResponse and BasePaginatedResponse default constructor and null-data tests - Add CreateOrderRequest CreatedBy/TimezoneId, empty items, and inheritance tests - Add OrderDto null items and multi-item collection tests --- .../UnitTests/Common/BaseRequestTests.cs | 85 ++++++++++++++++++- .../UnitTests/Common/BaseResponseTests.cs | 59 +++++++++++++ .../Orders/CreateOrderRequestTests.cs | 54 ++++++++++++ .../tests/UnitTests/Orders/OrderDtoTests.cs | 36 ++++++++ 4 files changed, 233 insertions(+), 1 deletion(-) diff --git a/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs b/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs index 4e722abe..1af719c4 100644 --- a/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs +++ b/templates/Contracts/tests/UnitTests/Common/BaseRequestTests.cs @@ -14,6 +14,24 @@ public void GivenABaseRequestWhenInstantiatedThenShouldAssignCorrelationId() Assert.Equal(correlationId, request.CorrelationId); } + [Fact(DisplayName = nameof(GivenABaseRequestWhenInstantiatedThenShouldAssignDefaultUserAndTimezone))] + public void GivenABaseRequestWhenInstantiatedThenShouldAssignDefaultUserAndTimezone() + { + var request = new BaseRequest(Guid.NewGuid()); + + Assert.Equal(string.Empty, request.User); + Assert.Equal(string.Empty, request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseRequestWhenInstantiatedWithCustomUserAndTimezoneThenShouldAssignValues))] + public void GivenABaseRequestWhenInstantiatedWithCustomUserAndTimezoneThenShouldAssignValues() + { + var request = new BaseRequest(Guid.NewGuid(), User: "alice", TimezoneId: "America/New_York"); + + Assert.Equal("alice", request.User); + Assert.Equal("America/New_York", request.TimezoneId); + } + [Fact(DisplayName = nameof(GivenABasePaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults))] public void GivenABasePaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults() { @@ -27,6 +45,8 @@ public void GivenABasePaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpe Assert.Null(request.SortBy); Assert.False(request.SortDescending); Assert.Null(request.SearchByValues); + Assert.Equal(string.Empty, request.User); + Assert.Equal(string.Empty, request.TimezoneId); } [Fact(DisplayName = nameof(GivenABasePaginatedRequestWhenInstantiatedThenShouldAssignCustomValues))] @@ -44,7 +64,9 @@ public void GivenABasePaginatedRequestWhenInstantiatedThenShouldAssignCustomValu PageSize: 25, SortBy: "CreatedAt", SortDescending: true, - SearchByValues: searchByValues + SearchByValues: searchByValues, + User: "bob", + TimezoneId: "Europe/London" ); Assert.Equal(correlationId, request.CorrelationId); @@ -53,5 +75,66 @@ public void GivenABasePaginatedRequestWhenInstantiatedThenShouldAssignCustomValu Assert.Equal("CreatedAt", request.SortBy); Assert.True(request.SortDescending); Assert.Equal(searchByValues, request.SearchByValues); + Assert.Equal("bob", request.User); + Assert.Equal("Europe/London", request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseFullTextSearchPaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults))] + public void GivenABaseFullTextSearchPaginatedRequestWhenUsingDefaultValuesThenShouldAssignExpectedDefaults() + { + var correlationId = Guid.NewGuid(); + + var request = new BaseFullTextSearchPaginatedRequest(correlationId); + + Assert.Equal(correlationId, request.CorrelationId); + Assert.Equal(1, request.Page); + Assert.Equal(10, request.PageSize); + Assert.Null(request.SortBy); + Assert.False(request.SortDescending); + Assert.Equal(string.Empty, request.SearchValue); + Assert.Equal(string.Empty, request.User); + Assert.Equal(string.Empty, request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseFullTextSearchPaginatedRequestWhenInstantiatedThenShouldAssignCustomValues))] + public void GivenABaseFullTextSearchPaginatedRequestWhenInstantiatedThenShouldAssignCustomValues() + { + var correlationId = Guid.NewGuid(); + + var request = new BaseFullTextSearchPaginatedRequest( + correlationId, + Page: 3, + PageSize: 20, + SortBy: "Name", + SortDescending: true, + SearchValue: "laptop", + User: "carol", + TimezoneId: "Asia/Tokyo" + ); + + Assert.Equal(correlationId, request.CorrelationId); + Assert.Equal(3, request.Page); + Assert.Equal(20, request.PageSize); + Assert.Equal("Name", request.SortBy); + Assert.True(request.SortDescending); + Assert.Equal("laptop", request.SearchValue); + Assert.Equal("carol", request.User); + Assert.Equal("Asia/Tokyo", request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenABaseFullTextSearchPaginatedRequestThenShouldInheritFromBaseRequest))] + public void GivenABaseFullTextSearchPaginatedRequestThenShouldInheritFromBaseRequest() + { + var request = new BaseFullTextSearchPaginatedRequest(Guid.NewGuid()); + + Assert.IsAssignableFrom(request); + } + + [Fact(DisplayName = nameof(GivenABasePaginatedRequestThenShouldInheritFromBaseRequest))] + public void GivenABasePaginatedRequestThenShouldInheritFromBaseRequest() + { + var request = new BasePaginatedRequest(Guid.NewGuid()); + + Assert.IsAssignableFrom(request); } } diff --git a/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs b/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs index eb5c7381..cbf0a31a 100644 --- a/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs +++ b/templates/Contracts/tests/UnitTests/Common/BaseResponseTests.cs @@ -23,6 +23,25 @@ public void GivenABaseResponseWhenInstantiatedThenShouldAssignSuccessAndMessage( Assert.Equal("ok", response.Message); } + [Fact(DisplayName = nameof(GivenABaseResponseWhenInstantiatedWithSuccessOnlyThenMessageShouldBeNull))] + public void GivenABaseResponseWhenInstantiatedWithSuccessOnlyThenMessageShouldBeNull() + { + var response = new BaseResponse(true); + + Assert.True(response.Success); + Assert.Null(response.Message); + } + + [Fact(DisplayName = nameof(GivenABaseResponseOfTDataWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues))] + public void GivenABaseResponseOfTDataWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues() + { + var response = new BaseResponse(); + + Assert.False(response.Success); + Assert.Null(response.Message); + Assert.Null(response.Data); + } + [Fact(DisplayName = nameof(GivenABaseResponseOfTDataWhenInstantiatedThenShouldAssignSuccessMessageAndData))] public void GivenABaseResponseOfTDataWhenInstantiatedThenShouldAssignSuccessMessageAndData() { @@ -40,6 +59,28 @@ public void GivenABaseResponseOfTDataWhenInstantiatedThenShouldAssignSuccessMess Assert.Equal(data, response.Data); } + [Fact(DisplayName = nameof(GivenABaseResponseOfTDataWhenInstantiatedWithNullDataThenDataShouldBeNull))] + public void GivenABaseResponseOfTDataWhenInstantiatedWithNullDataThenDataShouldBeNull() + { + var response = new BaseResponse(false, null, "not found"); + + Assert.False(response.Success); + Assert.Equal("not found", response.Message); + Assert.Null(response.Data); + } + + [Fact(DisplayName = nameof(GivenABasePaginatedResponseWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues))] + public void GivenABasePaginatedResponseWhenInstantiatedWithDefaultConstructorThenShouldHaveDefaultValues() + { + var response = new BasePaginatedResponse(); + + Assert.False(response.Success); + Assert.Null(response.Message); + Assert.Null(response.Data); + Assert.Equal(0, response.TotalPages); + Assert.Equal(0, response.TotalRecords); + } + [Fact(DisplayName = nameof(GivenABasePaginatedResponseWhenInstantiatedThenShouldAssignPaginationAndData))] public void GivenABasePaginatedResponseWhenInstantiatedThenShouldAssignPaginationAndData() { @@ -68,4 +109,22 @@ public void GivenABasePaginatedResponseWhenInstantiatedThenShouldAssignPaginatio Assert.Equal(30, response.TotalRecords); Assert.Equal(data, response.Data); } + + [Fact(DisplayName = nameof(GivenABasePaginatedResponseWhenInstantiatedWithNullDataThenDataShouldBeNull))] + public void GivenABasePaginatedResponseWhenInstantiatedWithNullDataThenDataShouldBeNull() + { + var response = new BasePaginatedResponse( + success: false, + totalPages: 0, + totalRecords: 0, + data: null, + message: "empty" + ); + + Assert.False(response.Success); + Assert.Equal("empty", response.Message); + Assert.Equal(0, response.TotalPages); + Assert.Equal(0, response.TotalRecords); + Assert.Null(response.Data); + } } diff --git a/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs b/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs index 277dab9e..0d907289 100644 --- a/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs +++ b/templates/Contracts/tests/UnitTests/Orders/CreateOrderRequestTests.cs @@ -1,3 +1,4 @@ +using Contracts.Common; using Contracts.Orders; namespace UnitTests.Orders; @@ -30,4 +31,57 @@ public void GivenACreateOrderRequestWhenInstantiatedThenShouldAssignProperties() Assert.Equal("Order description", request.Description); Assert.Equal(items, request.Items); } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenInstantiatedWithDefaultOptionalParamsThenShouldAssignEmptyStrings))] + public void GivenACreateOrderRequestWhenInstantiatedWithDefaultOptionalParamsThenShouldAssignEmptyStrings() + { + var request = new CreateOrderRequest(Guid.NewGuid(), "Order description", []); + + Assert.Equal(string.Empty, request.CreatedBy); + Assert.Equal(string.Empty, request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenInstantiatedWithCustomCreatedByAndTimezoneThenShouldAssignValues))] + public void GivenACreateOrderRequestWhenInstantiatedWithCustomCreatedByAndTimezoneThenShouldAssignValues() + { + var request = new CreateOrderRequest( + Guid.NewGuid(), + "Order description", + [], + CreatedBy: "alice", + TimezoneId: "America/Sao_Paulo" + ); + + Assert.Equal("alice", request.CreatedBy); + Assert.Equal("America/Sao_Paulo", request.TimezoneId); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestThenShouldInheritFromBaseRequest))] + public void GivenACreateOrderRequestThenShouldInheritFromBaseRequest() + { + var request = new CreateOrderRequest(Guid.NewGuid(), "desc", []); + + Assert.IsAssignableFrom(request); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenCreatedByIsSetThenUserPropertyShouldMatchCreatedBy))] + public void GivenACreateOrderRequestWhenCreatedByIsSetThenUserPropertyShouldMatchCreatedBy() + { + var request = new CreateOrderRequest( + Guid.NewGuid(), + "desc", + [], + CreatedBy: "bob" + ); + + Assert.Equal("bob", request.User); + } + + [Fact(DisplayName = nameof(GivenACreateOrderRequestWhenInstantiatedWithEmptyItemsThenItemsShouldBeEmpty))] + public void GivenACreateOrderRequestWhenInstantiatedWithEmptyItemsThenItemsShouldBeEmpty() + { + var request = new CreateOrderRequest(Guid.NewGuid(), "desc", []); + + Assert.Empty(request.Items); + } } diff --git a/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs b/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs index 69399446..c2e29497 100644 --- a/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs +++ b/templates/Contracts/tests/UnitTests/Orders/OrderDtoTests.cs @@ -32,6 +32,42 @@ public void GivenAnOrderDtoWhenPropertiesAreAssignedThenShouldExposeExpectedValu Assert.Equal(items, orderDto.Items); } + [Fact(DisplayName = nameof(GivenAnOrderDtoWhenItemsIsNullThenShouldExposeNullItems))] + public void GivenAnOrderDtoWhenItemsIsNullThenShouldExposeNullItems() + { + var orderDto = new OrderDto + { + Id = 1, + Description = "Order with no items", + Total = 0m, + Items = null + }; + + Assert.Null(orderDto.Items); + } + + [Fact(DisplayName = nameof(GivenAnOrderDtoWhenMultipleItemsAreAssignedThenShouldExposeAllItems))] + public void GivenAnOrderDtoWhenMultipleItemsAreAssignedThenShouldExposeAllItems() + { + ItemDto[] items = + [ + new() { Id = 1, Name = "Item A", Description = "Desc A", Value = 10m }, + new() { Id = 2, Name = "Item B", Description = "Desc B", Value = 20m }, + new() { Id = 3, Name = "Item C", Description = "Desc C", Value = 30m } + ]; + + var orderDto = new OrderDto + { + Id = 5, + Description = "Multi-item order", + Total = 60m, + Items = items + }; + + Assert.Equal(3, orderDto.Items!.Count); + Assert.Equal(60m, orderDto.Total); + } + [Fact(DisplayName = nameof(GivenAnItemDtoWhenPropertiesAreAssignedThenShouldExposeExpectedValues))] public void GivenAnItemDtoWhenPropertiesAreAssignedThenShouldExposeExpectedValues() { From de4af84b9116b5afbe8fac98da856b6bacce77fe Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 09:04:07 -0300 Subject: [PATCH 09/18] feat: improve request on contracts templates --- GPreviatti.Template.Hexagonal.Solution.csproj | 3 +- .../src/Contracts/Common/BaseRequest.cs | 34 +++++++++++++++++-- .../Contracts/Orders/CreateOrderRequest.cs | 8 +++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/GPreviatti.Template.Hexagonal.Solution.csproj b/GPreviatti.Template.Hexagonal.Solution.csproj index d3b09388..f8acfbf1 100644 --- a/GPreviatti.Template.Hexagonal.Solution.csproj +++ b/GPreviatti.Template.Hexagonal.Solution.csproj @@ -18,7 +18,8 @@ git://github.com/gpreviatti/hexagonal-solution-template true - - Remove unnecessary library from Bff project + - Add full text search use case on Full template + - Add full text search request and response on Contracts template diff --git a/templates/Contracts/src/Contracts/Common/BaseRequest.cs b/templates/Contracts/src/Contracts/Common/BaseRequest.cs index 43692755..30d99b38 100644 --- a/templates/Contracts/src/Contracts/Common/BaseRequest.cs +++ b/templates/Contracts/src/Contracts/Common/BaseRequest.cs @@ -4,7 +4,9 @@ /// Base request structure /// /// The unique identifier for correlating requests -public record BaseRequest(Guid CorrelationId); +/// The user making the request +/// The timezone identifier for the request +public record BaseRequest(Guid CorrelationId, string User = "", string TimezoneId = ""); /// /// Base paginated request structure @@ -15,11 +17,37 @@ public record BaseRequest(Guid CorrelationId); /// The field to sort by /// Indicates whether the sorting is in descending order /// A dictionary of search criteria +/// The user making the request +/// The timezone identifier for the request public record BasePaginatedRequest( Guid CorrelationId, int Page = 1, int PageSize = 10, string? SortBy = null, bool SortDescending = false, - Dictionary? SearchByValues = null -) : BaseRequest(CorrelationId); + Dictionary? SearchByValues = null, + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); + +/// +/// Represents a request for paginated data with full-text search capabilities. +/// +/// The unique identifier for correlating requests +/// The page number to retrieve +/// The number of items per page +/// The field to sort by +/// Indicates whether the sorting is in descending order +/// The value to search for within the full-text index e.g. "search term" +/// The user making the request +/// The timezone identifier for the request +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + int Page = 1, + int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs b/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs index 3f6ec283..c462b3cd 100644 --- a/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs +++ b/templates/Contracts/src/Contracts/Orders/CreateOrderRequest.cs @@ -8,11 +8,15 @@ namespace Contracts.Orders; /// The unique identifier for the request /// A description of the order /// The items included in the order +/// The user creating the order +/// The timezone identifier for the request public sealed record CreateOrderRequest( Guid CorrelationId, string Description, - CreateOrderItemRequest[] Items -) : BaseRequest(CorrelationId); + CreateOrderItemRequest[] Items, + string CreatedBy = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, CreatedBy, TimezoneId); /// /// An item included in the order From 41e46ee23fada85c1fc676107c320a114b06dc4a Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 09:11:05 -0300 Subject: [PATCH 10/18] docs: add full-text search guidance to AGENTS.md Documents when and how to use FullTextSearchPaginated for use cases that return multiple records, and makes the required GIN tsvector migration index explicit. Co-Authored-By: Claude --- templates/Full/AGENTS.md | 59 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/templates/Full/AGENTS.md b/templates/Full/AGENTS.md index aaad4e79..cc3c5883 100644 --- a/templates/Full/AGENTS.md +++ b/templates/Full/AGENTS.md @@ -155,6 +155,46 @@ Orchestrates domain objects and coordinates infrastructure through **ports (inte - **Logging:** Use structured logging with `ILogger` - **Notification publishing:** Use `CreateNotification()` helper or `IProduceService.ProduceAsync()` for async messaging +#### Full-Text Search Use Cases (returning multiple records) + +When a use case must return **more than one record**, use the full-text search paginated pattern: + +- **Request:** Use `BaseFullTextSearchPaginatedRequest` (provides `Page`, `PageSize`, `SortBy`, `SortDescending`, `SearchValue`) +- **Response:** Return `BasePaginatedResponse` +- **Repository call:** `GetAllFullTextSearchPaginatedAsync`, passing `nameof(Entity.ColumnName)` as `searchQuery` and `request.SearchValue` as `searchValue` +- **Language:** Defaults to `"english"` inside the repository — do not pass it from the request +- **⚠️ Index required:** A GIN tsvector index **must** exist on the searched column(s) for the query to work — see Step 3 below + +```csharp +public sealed class GetAllOrdersUseCase : BaseInOutUseCase> +{ + private readonly IBaseRepository _repository; + + public GetAllOrdersUseCase(IBaseRepository repository) : base(validator) + => _repository = repository; + + public override async Task>> Execute( + BaseFullTextSearchPaginatedRequest request, CancellationToken cancellationToken) + { + var (items, total) = await _repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() { Id = o.Id, Description = o.Description }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), // column to search within + request.SearchValue + ); + + return items.Any() + ? Result.Success(new BasePaginatedResponse(items, total, request.Page, request.PageSize)) + : Result.Failure>("No orders found."); + } +} +``` + **Example use case structure:** ```csharp namespace Application.Orders; @@ -226,8 +266,9 @@ Implements ports from Application. Contains all adapters for data, caching, mess - Use Fluent API for all configuration - Configure: primary key, columns, relationships, enums, precision, and default values - Use `builder.HasQueryFilter(p => !p.IsDeleted)` for soft delete filtering +- **Full-text search index:** When an entity is used with `GetAllFullTextSearchPaginatedAsync`, add a GIN tsvector index on the searched column(s) -**Example mapping:** +**Example mapping (with full-text search index):** ```csharp public sealed class OrderConfiguration : IEntityTypeConfiguration { @@ -238,10 +279,22 @@ public sealed class OrderConfiguration : IEntityTypeConfiguration builder.Property(x => x.TotalAmount).HasPrecision(18, 2); builder.Property(x => x.Status).HasConversion(); builder.HasQueryFilter(p => !p.IsDeleted); + + // Required for GetAllFullTextSearchPaginatedAsync — single column + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); + + // Multi-column variant + // builder.HasIndex(p => new { p.Name, p.Description }) + // .HasMethod("GIN") + // .IsTsVectorExpressionIndex("english"); } } ``` +> The GIN index must be created via migration before the endpoint is used in any environment. Without it, full-text search queries will either fail or fall back to a sequential scan. + #### RabbitMQ Consumers - Inherit from `BaseConsumer` where `TMessage` is the integration message and `TConsumer` is the consumer class itself - Implement `HandleUseCaseAsync(IServiceProvider, TMessage, CancellationToken)` abstract method @@ -284,6 +337,7 @@ Entry point for HTTP/gRPC requests. Minimal API endpoints. - `PUT /{id}` → `200 OK` or `400 BadRequest` - `DELETE /{id}` → `200 OK` or `400 BadRequest` - `POST /paginated` → `200 OK` or `400 BadRequest` + - `POST /full-text-search-paginated` → `200 OK` or `400 BadRequest` - **Sealed classes:** Endpoint handler classes should be sealed - **Request/Response types:** Use `BaseResponse` for single items, `BasePaginatedResponse` for lists @@ -881,18 +935,21 @@ Always follow the dependency direction: **Domain → Application → Infrastruct - **Validators:** Create `{Operation}RequestValidator.cs` inheriting from `AbstractValidator` - **Port usage:** Inject `IBaseRepository`, `IProduceService`, `IHybridCacheService` as needed - **Example:** `CreateProductUseCase.cs`, `GetProductUseCase.cs`, `UpdateProductUseCase.cs` +- **Returning multiple records?** Use `BaseFullTextSearchPaginatedRequest` + `GetAllFullTextSearchPaginatedAsync` — see [Full-Text Search Use Cases](#full-text-search-use-cases-returning-multiple-records) ### Step 3: Infrastructure Persistence - **EF Core mapping:** `src/Infrastructure/Data/Mapping/{Entity}Configuration.cs` - Implement `IEntityTypeConfiguration` - Configure key, columns, constraints, enums, precision, relationships - Always add `builder.HasQueryFilter(p => !p.IsDeleted)` for soft deletes + - **If the use case uses full-text search:** add a GIN tsvector index via `HasIndex(...).HasMethod("GIN").IsTsVectorExpressionIndex("english")` — the migration must include this index or queries will fail - **Custom repository** (if needed): `src/Infrastructure/Data/Repositories/{Entity}Repository.cs` - **Migration:** ```bash dotnet ef migrations add Add{Feature} --project src/Infrastructure --startup-project src/Infrastructure --output-dir Data/Migrations dotnet ef database update --project src/Infrastructure --startup-project src/Infrastructure ``` + > When adding full-text search support to an existing entity, create a dedicated migration (e.g., `Add{Entity}FullTextSearchSupport`) so the index is tracked separately from schema changes. ### Step 4: WebApp Endpoints - **File:** `src/WebApp/Endpoints/{Feature}Endpoints.cs` From 424a6f8505ed7c86de5b2a7f4c7c5e8b5f9c2abc Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 09:11:31 -0300 Subject: [PATCH 11/18] feat: update AGENTS.md to include BaseFullTextSearchPaginatedRequest --- templates/Contracts/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/Contracts/AGENTS.md b/templates/Contracts/AGENTS.md index dd52202a..59e15515 100644 --- a/templates/Contracts/AGENTS.md +++ b/templates/Contracts/AGENTS.md @@ -35,7 +35,7 @@ tests/UnitTests/ ### C# Contracts - All contracts are `sealed record` — immutable, serialization-safe, value-equal by default -- All requests **must** extend `BaseRequest(Guid CorrelationId)` or `BasePaginatedRequest` +- All requests **must** extend `BaseRequest(Guid CorrelationId)` or `BasePaginatedRequest` or `BaseFullTextSearchPaginatedRequest` - All responses **must** extend `BaseResponse(bool IsSuccess, string Message)` - Use file-scoped namespaces (`namespace Contracts.[Domain];`) - Nullable reference types are enabled — annotate all nullable properties with `?` From b1fc4004b9694998366c60329b914e7b94a1a767 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 09:53:54 -0300 Subject: [PATCH 12/18] feat: add full-text search use case to Simple template - Add BaseFullTextSearchPaginatedRequest to BaseRequest - Add GetAllFullTextSearchPaginatedAsync to IBaseRepository and BaseRepository - Add GIN indexes on Order.Description and Item.[Name, Description] via mapping classes - Add EF Core migration generated via dotnet ef - Add GetAllFullTextSearchOrdersUseCase and POST /orders/full-text-search-paginated endpoint - Add unit and integration tests Co-Authored-By: Claude --- .../Common/Repositories/IBaseRepository.cs | 15 + .../src/Core/Common/Requests/BaseRequest.cs | 11 + .../GetAllFullTextSearchOrdersUseCase.cs | 40 ++ .../Data/Common/BaseRepository.cs | 41 ++ .../Data/Mapping/ItemDbMapping.cs | 5 + .../Data/Mapping/OrderDbMapping.cs | 5 + ...30124653_FullTextSearchSupport.Designer.cs | 236 +++++++++ .../20260730124653_FullTextSearchSupport.cs | 41 ++ .../Migrations/MyDbContextModelSnapshot.cs | 456 +++++++++--------- .../src/WebApp/Endpoints/OrderEndpoints.cs | 11 + .../Orders/GetAllFullTextSearchOrdersTest.cs | 102 ++++ .../Core/Common/RepositoryMockExtensions.cs | 50 ++ .../GetAllFullTextSearchOrdersUseCaseTests.cs | 95 ++++ 13 files changed, 885 insertions(+), 223 deletions(-) create mode 100644 templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs create mode 100644 templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs create mode 100644 templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs create mode 100644 templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs create mode 100644 templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs diff --git a/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs b/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs index d4c3d131..e70ff85c 100644 --- a/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs +++ b/templates/Simple/src/Core/Common/Repositories/IBaseRepository.cs @@ -36,4 +36,19 @@ params Expression>[]? includes Expression> predicate = null!, bool? newContext = null ) where TEntity : DomainEntity; + + Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity; } diff --git a/templates/Simple/src/Core/Common/Requests/BaseRequest.cs b/templates/Simple/src/Core/Common/Requests/BaseRequest.cs index 083e388b..5d43ecdb 100644 --- a/templates/Simple/src/Core/Common/Requests/BaseRequest.cs +++ b/templates/Simple/src/Core/Common/Requests/BaseRequest.cs @@ -15,3 +15,14 @@ public record BasePaginatedRequest( string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); + +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + [property: Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] int Page = 1, + [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs new file mode 100644 index 00000000..c1b5591e --- /dev/null +++ b/templates/Simple/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -0,0 +1,40 @@ +using Core.Common.Helpers; +using Core.Common.Requests; +using Core.Common.UseCases; + +namespace Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCase(IServiceProvider serviceProvider) : BaseInOutUseCase>(serviceProvider) +{ + public override async Task> HandleInternalAsync( + BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) + { + var (orders, totalRecords) = await Repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() + { + Id = o.Id, + Total = o.Total + }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), + request.SearchValue + ); + + if (orders is null || !orders.Any()) + { + Logs.NotFound(Logger, request.CorrelationId, nameof(orders)); + return new(false, 0, 0, [], "No orders found."); + } + + var totalPages = (int) Math.Ceiling(totalRecords / (double) request.PageSize); + + return new(true, totalPages, totalRecords, orders); + } +} diff --git a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs index 002a8a83..f57edcba 100644 --- a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs @@ -190,4 +190,45 @@ params Expression>[]? includes return (items, totalRecords); }, correlationId, newContext); + + public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => + { + var query = dbEntitySet.AsQueryable(); + + if (predicate != null) + query = query.Where(predicate); + + if (!string.IsNullOrWhiteSpace(sortBy)) + query = sortDescending + ? query.OrderByDescending(e => EF.Property(e, sortBy)) + : query.OrderBy(e => EF.Property(e, sortBy)); + else + query = query.OrderBy(e => e.CreatedAt); + + var totalRecords = await query.CountAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, searchQuery).Matches(searchValue)); + + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(selector) + .ToListAsync(cancellationToken); + + return (items, totalRecords); + }, correlationId, newContext); } diff --git a/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs b/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs index b95207e7..492e3f30 100644 --- a/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs +++ b/templates/Simple/src/Infrastructure/Data/Mapping/ItemDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -19,5 +20,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) builder.Property(p => p.Description) .HasMaxLength(255) .IsRequired(); + + builder.HasIndex(p => new { p.Name, p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs b/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs index 4bcd88f7..daa19a1f 100644 --- a/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs +++ b/templates/Simple/src/Infrastructure/Data/Mapping/OrderDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -17,5 +18,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) .HasPrecision(18, 2); builder.HasMany(p => p.Items); + + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs new file mode 100644 index 00000000..500c3e13 --- /dev/null +++ b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + [Migration("20260730124653_FullTextSearchSupport")] + partial class FullTextSearchSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.HasOne("Core.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs new file mode 100644 index 00000000..31faba64 --- /dev/null +++ b/templates/Simple/src/Infrastructure/Data/Migrations/20260730124653_FullTextSearchSupport.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations; + +/// +public partial class FullTextSearchSupport : Migration +{ + private static readonly string[] s_columns = ["Name", "Description"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); + + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); + } +} diff --git a/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs b/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs index 9c3d97f6..8c6708ab 100644 --- a/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs +++ b/templates/Simple/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs @@ -1,223 +1,233 @@ -// -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(MyDbContext))] - partial class MyDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Core.Notifications.Notification", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Message") - .HasMaxLength(4000) - .HasColumnType("jsonb"); - - b.Property("NotificationStatus") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("NotificationType") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Notification"); - }); - - modelBuilder.Entity("Core.Orders.Item", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("text"); - - b.Property("OrderId") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Value") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("OrderId"); - - b.ToTable("Item"); - }); - - modelBuilder.Entity("Core.Orders.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("CreatedByTimezoneId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("text"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("Total") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(50) - .HasColumnType("text"); - - b.Property("UpdatedByTimezoneId") - .HasMaxLength(50) - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Order"); - }); - - modelBuilder.Entity("Core.Orders.Item", b => - { - b.HasOne("Core.Orders.Order", null) - .WithMany("Items") - .HasForeignKey("OrderId"); - }); - - modelBuilder.Entity("Core.Orders.Order", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + partial class MyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.HasOne("Core.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs b/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs index b7eeca6b..65eabfcd 100644 --- a/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs +++ b/templates/Simple/src/WebApp/Endpoints/OrderEndpoints.cs @@ -62,6 +62,17 @@ CancellationToken cancellationToken return response.Success ? Results.Ok(response) : Results.BadRequest(response); }); + ordersGroup.MapPost("/full-text-search-paginated", async ( + [FromServices] IBaseInOutUseCase> useCase, + [FromBody] BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) => + { + var response = await useCase.HandleAsync(request, cancellationToken); + + return response.Success ? Results.Ok(response) : Results.BadRequest(response); + }); + ordersGroup.MapPut("/{id}", async ( [FromServices] IBaseInOutUseCase> useCase, [FromBody] UpdateOrderRequest request, diff --git a/templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs b/templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs new file mode 100644 index 00000000..f24a2391 --- /dev/null +++ b/templates/Simple/tests/IntegrationTests/WebApp/Http/Orders/GetAllFullTextSearchOrdersTest.cs @@ -0,0 +1,102 @@ +using System.Net; +using Core.Common.Requests; +using Core.Orders; +using IntegrationTests.Common; +using IntegrationTests.WebApp.Http.Common; +using WebApp; + +namespace IntegrationTests.WebApp.Http.Orders; + +public class GetAllFullTextSearchOrdersTestFixture : BaseHttpFixture +{ + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); + public static BaseFullTextSearchPaginatedRequest SetInvalidPageSizeRequest() => new(Guid.NewGuid(), 1, 0); +} + +[Collection(nameof(WebApplicationFactoryCollectionDefinition))] +public class GetAllFullTextSearchOrdersTest : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersTestFixture _fixture; + + public GetAllFullTextSearchOrdersTest(CustomWebApplicationFactory customWebApplicationFactory, GetAllFullTextSearchOrdersTestFixture fixture) + { + _fixture = fixture; + _fixture.SetApiHelper(customWebApplicationFactory); + _fixture.ResourceUrl = "orders/full-text-search-paginated"; + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.True(response!.Success); + Assert.NotNull(response.Data); + Assert.True(response.TotalPages >= 0); + Assert.True(response.TotalRecords >= 0); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("Page must be greater than 0", response.Message); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageSizeRequestThenFails))] + public async Task GivenAnInvalidPageSizeRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetInvalidPageSizeRequest(); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.False(response!.Success); + Assert.Contains("PageSize must be between 1 and 100", response.Message); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWithSearchValueThenPass))] + public async Task GivenAValidRequestWithSearchValueThenPass() + { + // Arrange + var request = GetAllFullTextSearchOrdersTestFixture.SetValidRequest(searchValue: "client"); + + // Act + var result = await _fixture.ApiHelper.PostAsync(_fixture.ResourceUrl, request); + var response = await ApiHelper.DeSerializeResponse>(result); + + // Assert + Assert.NotNull(result); + Assert.NotNull(response); + Assert.True( + result.StatusCode == HttpStatusCode.OK || result.StatusCode == HttpStatusCode.BadRequest, + $"Unexpected status code: {result.StatusCode}" + ); + if (result.StatusCode == HttpStatusCode.BadRequest) + Assert.Equal("No orders found.", response.Message); + } +} diff --git a/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs b/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs index 54939c25..f0366b99 100644 --- a/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs +++ b/templates/Simple/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs @@ -42,6 +42,56 @@ public static void SetFailedUpdate(this Mock mockRepos .Setup(d => d.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(0); + public static void SetValidGetAllFullTextSearchPaginatedAsync( + this Mock mockRepository, + IEnumerable data, + int totalRecords + ) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync((data, totalRecords)); + + public static void SetInvalidGetAllFullTextSearchPaginatedAsync(this Mock mockRepository) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync(([], 0)); + + public static void VerifyGetAllFullTextSearchPaginated(this Mock mockRepository, int times = 1) where TEntity : DomainEntity where TResult : class => mockRepository + .Verify(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), Times.Exactly(times)); + public static void SetValidGetAllPaginatedAsyncNoIncludes( this Mock mockRepository, IEnumerable data, diff --git a/templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs new file mode 100644 index 00000000..efb95c20 --- /dev/null +++ b/templates/Simple/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -0,0 +1,95 @@ +using Core.Common.Requests; +using Core.Orders; +using UnitTests.Core.Common; + +namespace UnitTests.Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseCoreFixture +{ + public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); + + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "client") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); +} + +public sealed class GetAllFullTextSearchOrdersUseCaseTests : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersUseCaseFixture _fixture; + + public GetAllFullTextSearchOrdersUseCaseTests(GetAllFullTextSearchOrdersUseCaseFixture fixture) + { + _fixture = fixture; + _fixture.ClearInvocations(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var totalRecords = 5; + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + var expectedOrders = _fixture.AutoFixture.CreateMany(totalRecords); + + _fixture.MockRepository.SetValidGetAllFullTextSearchPaginatedAsync(expectedOrders, totalRecords); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Null(result.Message); + Assert.NotNull(result.Data); + Assert.Equal(expectedOrders.Count(), result.Data.Count()); + Assert.Equal(1, result.TotalPages); + Assert.Equal(totalRecords, result.TotalRecords); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWhenNoOrdersFoundThenFails))] + public async Task GivenAValidRequestWhenNoOrdersFoundThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + _fixture.MockRepository.SetInvalidGetAllFullTextSearchPaginatedAsync(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + Assert.Equal("No orders found.", result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(1); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(0); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(0); + } +} From 4fef03e7198f42d07b057996d3596c12a34a6ae7 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 10:10:03 -0300 Subject: [PATCH 13/18] feat: add full-text search use case to SimpleWebUi template - Add BaseFullTextSearchPaginatedRequest to BaseRequest - Add GetAllFullTextSearchPaginatedAsync to IBaseRepository and BaseRepository - Add GIN indexes on Order.Description and Item.[Name, Description] via mapping classes - Add EF Core migration generated via dotnet ef - Add GetAllFullTextSearchOrdersUseCase (auto-registered by DI) - Replace OrderList ILike search with full-text search use case - Update OrderList unit tests to use BaseFullTextSearchPaginatedRequest - Add GetAllFullTextSearchOrdersUseCaseTests unit tests - Add full-text search mock helpers to RepositoryMockExtensions Co-Authored-By: Claude --- .../Common/Repositories/IBaseRepository.cs | 15 ++ .../src/Core/Common/Requests/BaseRequest.cs | 11 + .../GetAllFullTextSearchOrdersUseCase.cs | 41 +++ .../Data/Common/BaseRepository.cs | 41 +++ .../Data/Mapping/ItemDbMapping.cs | 5 + .../Data/Mapping/OrderDbMapping.cs | 5 + ...30130753_FullTextSearchSupport.Designer.cs | 236 ++++++++++++++++++ .../20260730130753_FullTextSearchSupport.cs | 42 ++++ .../Migrations/MyDbContextModelSnapshot.cs | 12 +- .../src/WebUI/Pages/Orders/OrderList.razor.cs | 11 +- .../Core/Common/RepositoryMockExtensions.cs | 50 ++++ .../GetAllFullTextSearchOrdersUseCaseTests.cs | 95 +++++++ .../WebUi/Pages/Orders/OrderListTests.cs | 10 +- 13 files changed, 562 insertions(+), 12 deletions(-) create mode 100644 templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs create mode 100644 templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs create mode 100644 templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs create mode 100644 templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs diff --git a/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs b/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs index d4c3d131..e70ff85c 100644 --- a/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs +++ b/templates/SimpleWebUi/src/Core/Common/Repositories/IBaseRepository.cs @@ -36,4 +36,19 @@ params Expression>[]? includes Expression> predicate = null!, bool? newContext = null ) where TEntity : DomainEntity; + + Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity; } diff --git a/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs b/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs index 083e388b..5d43ecdb 100644 --- a/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs +++ b/templates/SimpleWebUi/src/Core/Common/Requests/BaseRequest.cs @@ -15,3 +15,14 @@ public record BasePaginatedRequest( string User = "", string TimezoneId = "" ) : BaseRequest(CorrelationId, User, TimezoneId); + +public record BaseFullTextSearchPaginatedRequest( + Guid CorrelationId, + [property: Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] int Page = 1, + [property: Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] int PageSize = 10, + string? SortBy = null, + bool SortDescending = false, + string SearchValue = "", + string User = "", + string TimezoneId = "" +) : BaseRequest(CorrelationId, User, TimezoneId); diff --git a/templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs b/templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs new file mode 100644 index 00000000..e0f6c5e8 --- /dev/null +++ b/templates/SimpleWebUi/src/Core/Orders/GetAllFullTextSearchOrdersUseCase.cs @@ -0,0 +1,41 @@ +using Core.Common.Helpers; +using Core.Common.Requests; +using Core.Common.UseCases; + +namespace Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCase(IServiceProvider serviceProvider) : BaseInOutUseCase>(serviceProvider) +{ + public override async Task> HandleInternalAsync( + BaseFullTextSearchPaginatedRequest request, + CancellationToken cancellationToken + ) + { + var (orders, totalRecords) = await Repository.GetAllFullTextSearchPaginatedAsync( + request.CorrelationId, + request.Page, + request.PageSize, + o => new() + { + Id = o.Id, + Description = o.Description, + Total = o.Total + }, + cancellationToken, + request.SortBy, + request.SortDescending, + nameof(Order.Description), + request.SearchValue + ); + + if (orders is null || !orders.Any()) + { + Logs.NotFound(Logger, request.CorrelationId, nameof(orders)); + return new(false, 0, 0, [], "No orders found."); + } + + var totalPages = (int) Math.Ceiling(totalRecords / (double) request.PageSize); + + return new(true, totalPages, totalRecords, orders); + } +} diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs index 002a8a83..f57edcba 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs @@ -190,4 +190,45 @@ params Expression>[]? includes return (items, totalRecords); }, correlationId, newContext); + + public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( + Guid correlationId, + int page, + int pageSize, + Expression> selector, + CancellationToken cancellationToken, + string? sortBy = null!, + bool sortDescending = false, + string searchQuery = null!, + string searchValue = null!, + string searchLanguage = "english", + Expression> predicate = null!, + bool? newContext = null + ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => + { + var query = dbEntitySet.AsQueryable(); + + if (predicate != null) + query = query.Where(predicate); + + if (!string.IsNullOrWhiteSpace(sortBy)) + query = sortDescending + ? query.OrderByDescending(e => EF.Property(e, sortBy)) + : query.OrderBy(e => EF.Property(e, sortBy)); + else + query = query.OrderBy(e => e.CreatedAt); + + var totalRecords = await query.CountAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, searchQuery).Matches(searchValue)); + + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(selector) + .ToListAsync(cancellationToken); + + return (items, totalRecords); + }, correlationId, newContext); } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs index b95207e7..492e3f30 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/ItemDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -19,5 +20,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) builder.Property(p => p.Description) .HasMaxLength(255) .IsRequired(); + + builder.HasIndex(p => new { p.Name, p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs index 4bcd88f7..daa19a1f 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Mapping/OrderDbMapping.cs @@ -1,5 +1,6 @@ using Core.Orders; using Infrastructure.Data.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Infrastructure.Data.Mapping; @@ -17,5 +18,9 @@ public override void ConfigureDomainEntity(EntityTypeBuilder builder) .HasPrecision(18, 2); builder.HasMany(p => p.Items); + + builder.HasIndex(p => new { p.Description }) + .HasMethod("GIN") + .IsTsVectorExpressionIndex("english"); } } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs new file mode 100644 index 00000000..3f6db4f7 --- /dev/null +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(MyDbContext))] + [Migration("20260730130753_FullTextSearchSupport")] + partial class FullTextSearchSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("jsonb"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notification"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("CreatedByTimezoneId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(50) + .HasColumnType("text"); + + b.Property("UpdatedByTimezoneId") + .HasMaxLength(50) + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("Core.Orders.Item", b => + { + b.HasOne("Core.Orders.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId"); + }); + + modelBuilder.Entity("Core.Orders.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs new file mode 100644 index 00000000..5a7e4e92 --- /dev/null +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// + public partial class FullTextSearchSupport : Migration + { + private static readonly string[] s_columns = ["Name", "Description"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); + + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); + } + } +} diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs index 2d7b8ca6..8c6708ab 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/MyDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -146,6 +146,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrderId"); + b.HasIndex("Name", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name", "Description"), "GIN"); + b.ToTable("Item"); }); @@ -203,6 +208,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + b.ToTable("Order"); }); diff --git a/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs b/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs index 6002299b..66c198be 100644 --- a/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs +++ b/templates/SimpleWebUi/src/WebUI/Pages/Orders/OrderList.razor.cs @@ -9,7 +9,7 @@ namespace WebUi.Pages.Orders; public partial class OrderList : IDisposable { - [Inject] private IBaseInOutUseCase> GetAllOrders { get; set; } = default!; + [Inject] private IBaseInOutUseCase> GetAllOrders { get; set; } = default!; [Inject] private IBaseInOutUseCase DeleteOrder { get; set; } = default!; private readonly CancellationTokenSource _cancellationTokenSource = new(); private List _orders = []; @@ -32,11 +32,10 @@ private async Task LoadAsync() _loading = true; _showError = false; - Dictionary? search = string.IsNullOrWhiteSpace(_searchDescription) - ? null - : new() { ["Description"] = _searchDescription }; - - var result = await GetAllOrders.HandleAsync(new(Guid.NewGuid(), _currentPage, PageSize, SearchByValues: search), _cancellationTokenSource.Token); + var result = await GetAllOrders.HandleAsync( + new(Guid.NewGuid(), _currentPage, PageSize, SearchValue: _searchDescription), + _cancellationTokenSource.Token + ); _loading = false; diff --git a/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs b/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs index 54939c25..f0366b99 100644 --- a/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs +++ b/templates/SimpleWebUi/tests/UnitTests/Core/Common/RepositoryMockExtensions.cs @@ -42,6 +42,56 @@ public static void SetFailedUpdate(this Mock mockRepos .Setup(d => d.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(0); + public static void SetValidGetAllFullTextSearchPaginatedAsync( + this Mock mockRepository, + IEnumerable data, + int totalRecords + ) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync((data, totalRecords)); + + public static void SetInvalidGetAllFullTextSearchPaginatedAsync(this Mock mockRepository) where TEntity : DomainEntity where TResult : class => mockRepository.Setup(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + )).ReturnsAsync(([], 0)); + + public static void VerifyGetAllFullTextSearchPaginated(this Mock mockRepository, int times = 1) where TEntity : DomainEntity where TResult : class => mockRepository + .Verify(r => r.GetAllFullTextSearchPaginatedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), Times.Exactly(times)); + public static void SetValidGetAllPaginatedAsyncNoIncludes( this Mock mockRepository, IEnumerable data, diff --git a/templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs b/templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs new file mode 100644 index 00000000..efb95c20 --- /dev/null +++ b/templates/SimpleWebUi/tests/UnitTests/Core/Orders/GetAllFullTextSearchOrdersUseCaseTests.cs @@ -0,0 +1,95 @@ +using Core.Common.Requests; +using Core.Orders; +using UnitTests.Core.Common; + +namespace UnitTests.Core.Orders; + +public sealed class GetAllFullTextSearchOrdersUseCaseFixture : BaseCoreFixture +{ + public GetAllFullTextSearchOrdersUseCaseFixture() => UseCase = new(MockServiceProvider.Object); + + public static BaseFullTextSearchPaginatedRequest SetValidRequest(string searchValue = "client") => new(Guid.NewGuid(), 1, 10, SearchValue: searchValue); + + public static BaseFullTextSearchPaginatedRequest SetInvalidPageRequest() => new(Guid.NewGuid(), 0, 10); +} + +public sealed class GetAllFullTextSearchOrdersUseCaseTests : IClassFixture +{ + private readonly GetAllFullTextSearchOrdersUseCaseFixture _fixture; + + public GetAllFullTextSearchOrdersUseCaseTests(GetAllFullTextSearchOrdersUseCaseFixture fixture) + { + _fixture = fixture; + _fixture.ClearInvocations(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestThenPass))] + public async Task GivenAValidRequestThenPass() + { + // Arrange + var totalRecords = 5; + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + var expectedOrders = _fixture.AutoFixture.CreateMany(totalRecords); + + _fixture.MockRepository.SetValidGetAllFullTextSearchPaginatedAsync(expectedOrders, totalRecords); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Null(result.Message); + Assert.NotNull(result.Data); + Assert.Equal(expectedOrders.Count(), result.Data.Count()); + Assert.Equal(1, result.TotalPages); + Assert.Equal(totalRecords, result.TotalRecords); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAValidRequestWhenNoOrdersFoundThenFails))] + public async Task GivenAValidRequestWhenNoOrdersFoundThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetValidRequest(); + _fixture.MockRepository.SetInvalidGetAllFullTextSearchPaginatedAsync(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + Assert.Equal("No orders found.", result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(1); + _fixture.MockLogger.VerifyNotFound(1); + _fixture.MockLogger.VerifyFinishOperation(); + } + + [Fact(DisplayName = nameof(GivenAnInvalidPageRequestThenFails))] + public async Task GivenAnInvalidPageRequestThenFails() + { + // Arrange + var request = GetAllFullTextSearchOrdersUseCaseFixture.SetInvalidPageRequest(); + + // Act + var result = await _fixture.UseCase.HandleAsync(request, _fixture.CancellationToken); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + Assert.NotEmpty(result.Message); + + _fixture.MockLogger.VerifyStartOperation(); + _fixture.MockRepository.VerifyGetAllFullTextSearchPaginated(0); + _fixture.MockLogger.VerifyNotFound(0); + _fixture.MockLogger.VerifyFinishOperation(0); + } +} diff --git a/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs b/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs index 3953073e..6b8697af 100644 --- a/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs +++ b/templates/SimpleWebUi/tests/UnitTests/WebUi/Pages/Orders/OrderListTests.cs @@ -9,7 +9,7 @@ namespace UnitTests.WebUi.Pages.Orders; public sealed class OrderListFixture : BaseComponentFixture { - public Mock>> MockGetAllOrders { get; } = new(); + public Mock>> MockGetAllOrders { get; } = new(); public Mock> MockDeleteOrder { get; } = new(); public OrderListFixture() @@ -20,7 +20,7 @@ public OrderListFixture() public void SetupGetAll(BasePaginatedResponse response) => MockGetAllOrders - .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) + .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(response); public static BasePaginatedResponse SuccessPage( @@ -78,7 +78,7 @@ public void GivenOrdersReturnedThenTableRowsAreRendered() public void GivenUseCaseFailsThenErrorAlertIsShown() { _fixture.MockGetAllOrders - .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) + .Setup(u => u.HandleAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new BasePaginatedResponse(false, 0, 0, null, "Load failed")); var component = _fixture.Render(); @@ -97,7 +97,7 @@ public void GivenSearchButtonClickThenReloadsFromPageOne() component.Find(Selectors.ButtonOutlineSecondary).Click(); _fixture.MockGetAllOrders.Verify(u => u.HandleAsync( - It.Is(r => r.Page == 1), + It.Is(r => r.Page == 1), It.IsAny()), Times.AtLeast(2)); } @@ -125,7 +125,7 @@ public void GivenNextButtonClickThenNavigatesToNextPage() component.Find(Selectors.LastChildLi).Click(); _fixture.MockGetAllOrders.Verify(u => u.HandleAsync( - It.Is(r => r.Page == 2), + It.Is(r => r.Page == 2), It.IsAny()), Times.Once); } From 753b38b9a614878ee3e2086cd0cafa2035ffbd1d Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 10:11:22 -0300 Subject: [PATCH 14/18] test: add e2e tests for full-text search on OrderList page Cover search button click, Enter key, and clearing the search term. Each test asserts the page shows either the table or the empty state, matching the tolerant pattern used by existing OrderList e2e tests. Co-Authored-By: Claude --- .../E2eTests/WebUI/OrderListPageTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs b/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs index 6ac046eb..d29565b1 100644 --- a/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs +++ b/templates/SimpleWebUi/tests/E2eTests/WebUI/OrderListPageTests.cs @@ -154,4 +154,56 @@ public async Task GivenSearchInputWhenTermEnteredThenSearchButtonIsPresent() Assert.NotNull(searchInput); Assert.NotNull(searchButton); } + + [Fact(DisplayName = nameof(GivenFullTextSearchWhenSearchButtonClickedThenEitherTableOrEmptyStateIsShown))] + public async Task GivenFullTextSearchWhenSearchButtonClickedThenEitherTableOrEmptyStateIsShown() + { + await _fixture.NavigateToOrdersAsync(); + await _fixture.Page.WaitForPageLoadAsync(); + + await _fixture.Page.FillAsync(Selectors.InputText, "client"); + await _fixture.Page.ClickAsync(Selectors.ButtonOutlineSecondary); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var tableVisible = await _fixture.Page.IsTableVisibleAsync(3000); + var emptyVisible = await _fixture.IsEmptyStateVisibleAsync(); + + Assert.True(tableVisible || emptyVisible, "Either the orders table or empty state must be shown after full-text search"); + } + + [Fact(DisplayName = nameof(GivenFullTextSearchWhenEnterPressedThenEitherTableOrEmptyStateIsShown))] + public async Task GivenFullTextSearchWhenEnterPressedThenEitherTableOrEmptyStateIsShown() + { + await _fixture.NavigateToOrdersAsync(); + await _fixture.Page.WaitForPageLoadAsync(); + + await _fixture.Page.FillAsync(Selectors.InputText, "order"); + await _fixture.Page.PressAsync(Selectors.InputText, "Enter"); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var tableVisible = await _fixture.Page.IsTableVisibleAsync(3000); + var emptyVisible = await _fixture.IsEmptyStateVisibleAsync(); + + Assert.True(tableVisible || emptyVisible, "Either the orders table or empty state must be shown after pressing Enter"); + } + + [Fact(DisplayName = nameof(GivenFullTextSearchWhenSearchClearedThenAllOrdersReload))] + public async Task GivenFullTextSearchWhenSearchClearedThenAllOrdersReload() + { + await _fixture.NavigateToOrdersAsync(); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await _fixture.Page.FillAsync(Selectors.InputText, "zzznomatch"); + await _fixture.Page.ClickAsync(Selectors.ButtonOutlineSecondary); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await _fixture.Page.FillAsync(Selectors.InputText, ""); + await _fixture.Page.ClickAsync(Selectors.ButtonOutlineSecondary); + await _fixture.Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var tableVisible = await _fixture.Page.IsTableVisibleAsync(3000); + var emptyVisible = await _fixture.IsEmptyStateVisibleAsync(); + + Assert.True(tableVisible || emptyVisible, "Either the orders table or empty state must be shown after clearing the search"); + } } From b01483720712a932571ba1383069960dc44dbb21 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 10:24:26 -0300 Subject: [PATCH 15/18] fix: correct case-sensitive path for WebUI project in Dockerfile and solution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker (Linux) is case-sensitive — the folder is src/WebUI/ but Dockerfile and slnx referenced src/WebUi/, causing the build to fail inside the container. Co-Authored-By: Claude --- templates/SimpleWebUi/Dockerfile | 7 +++---- templates/SimpleWebUi/SimpleWebUi.slnx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/templates/SimpleWebUi/Dockerfile b/templates/SimpleWebUi/Dockerfile index 316e2455..411a32d8 100644 --- a/templates/SimpleWebUi/Dockerfile +++ b/templates/SimpleWebUi/Dockerfile @@ -1,13 +1,12 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build-env +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-env WORKDIR /app COPY . . -WORKDIR /app/src/WebUi RUN dotnet restore \ - && dotnet publish -c Release -o /app/out + && dotnet publish src/WebUI/WebUi.csproj -c Release -o /app/out -FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 +FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build-env /app/out . diff --git a/templates/SimpleWebUi/SimpleWebUi.slnx b/templates/SimpleWebUi/SimpleWebUi.slnx index 334ec686..025c7e03 100644 --- a/templates/SimpleWebUi/SimpleWebUi.slnx +++ b/templates/SimpleWebUi/SimpleWebUi.slnx @@ -9,7 +9,7 @@ - + From 443a85c4c8db9513e8a53697433042eb66afe703 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 10:49:19 -0300 Subject: [PATCH 16/18] fix: use EF.Property to reference column in full-text search predicate ToTsVector was receiving the column name as a plain string literal, causing Postgres to create a tsvector of the word "Description" rather than the actual column value. Switch to EF.Property(e, searchQuery) so the correct column reference is emitted in the generated SQL. Co-Authored-By: Claude --- templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs | 2 +- .../Simple/src/Infrastructure/Data/Common/BaseRepository.cs | 2 +- .../src/Infrastructure/Data/Common/BaseRepository.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs index 8f35d1f7..dcd9a284 100644 --- a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs @@ -138,7 +138,7 @@ await HandleBaseQueryAsync(async dbEntitySet => var totalRecords = await query.CountAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) - query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, searchQuery).Matches(searchValue)); + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); var items = await query .Skip((page - 1) * pageSize) diff --git a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs index f57edcba..59aedd9b 100644 --- a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs @@ -221,7 +221,7 @@ params Expression>[]? includes var totalRecords = await query.CountAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) - query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, searchQuery).Matches(searchValue)); + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); var items = await query .Skip((page - 1) * pageSize) diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs index f57edcba..59aedd9b 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs @@ -221,7 +221,7 @@ params Expression>[]? includes var totalRecords = await query.CountAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) - query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, searchQuery).Matches(searchValue)); + query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); var items = await query .Skip((page - 1) * pageSize) From b42ecb714b631820c6a704ce4dcf6bd23507ec78 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 15:09:00 -0300 Subject: [PATCH 17/18] refactor: parallelize paginated count queries and bump bunit to 2.8.6 Run totalRecords and items queries concurrently in both GetAllPaginatedAsync and GetAllFullTextSearchPaginatedAsync using Task.WhenAll. Also upgrades bunit from 2.7.2 to 2.8.6 with its transitive dependency updates, loosens the Dockerfile SDK pin to 10.0, removes a docker-compose YAML anchor, and adds a release note for the full-text search feature. --- GPreviatti.Template.Hexagonal.Solution.csproj | 1 + .../SimpleWebUi/Directory.Packages.props | 2 +- templates/SimpleWebUi/Dockerfile.migrate | 2 +- templates/SimpleWebUi/docker-compose-e2e.yml | 2 +- .../Data/Common/BaseRepository.cs | 25 +- .../20260730130753_FullTextSearchSupport.cs | 59 +++-- .../tests/UnitTests/packages.lock.json | 230 +++++++++--------- 7 files changed, 166 insertions(+), 155 deletions(-) diff --git a/GPreviatti.Template.Hexagonal.Solution.csproj b/GPreviatti.Template.Hexagonal.Solution.csproj index f8acfbf1..23b5eb07 100644 --- a/GPreviatti.Template.Hexagonal.Solution.csproj +++ b/GPreviatti.Template.Hexagonal.Solution.csproj @@ -20,6 +20,7 @@ - Add full text search use case on Full template - Add full text search request and response on Contracts template + - Add full text search implementation on Simple template diff --git a/templates/SimpleWebUi/Directory.Packages.props b/templates/SimpleWebUi/Directory.Packages.props index e0886fd2..06da13a0 100644 --- a/templates/SimpleWebUi/Directory.Packages.props +++ b/templates/SimpleWebUi/Directory.Packages.props @@ -7,7 +7,7 @@ - + diff --git a/templates/SimpleWebUi/Dockerfile.migrate b/templates/SimpleWebUi/Dockerfile.migrate index 2dc5533c..53fd9dbf 100644 --- a/templates/SimpleWebUi/Dockerfile.migrate +++ b/templates/SimpleWebUi/Dockerfile.migrate @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0.300 +FROM mcr.microsoft.com/dotnet/sdk:10.0 WORKDIR /app diff --git a/templates/SimpleWebUi/docker-compose-e2e.yml b/templates/SimpleWebUi/docker-compose-e2e.yml index c126f505..22e0c7cc 100644 --- a/templates/SimpleWebUi/docker-compose-e2e.yml +++ b/templates/SimpleWebUi/docker-compose-e2e.yml @@ -25,7 +25,7 @@ services: condition: service_healthy postgres: - image: &postgresImage postgres:17-alpine + image: postgres:17-alpine container_name: postgres environment: POSTGRES_PASSWORD: "cY5VvZkkh4AzES" diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs index 59aedd9b..3437a6b9 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Common/BaseRepository.cs @@ -162,6 +162,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -174,7 +179,6 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); if (searchByValues != null && searchByValues.Count != 0) foreach (var searchByValue in searchByValues) @@ -182,13 +186,15 @@ params Expression>[]? includes EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") ); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( @@ -206,6 +212,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -218,17 +229,17 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); } diff --git a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs index 5a7e4e92..31faba64 100644 --- a/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs +++ b/templates/SimpleWebUi/src/Infrastructure/Data/Migrations/20260730130753_FullTextSearchSupport.cs @@ -2,41 +2,40 @@ #nullable disable -namespace Infrastructure.Data.Migrations +namespace Infrastructure.Data.Migrations; + +/// +public partial class FullTextSearchSupport : Migration { + private static readonly string[] s_columns = ["Name", "Description"]; + /// - public partial class FullTextSearchSupport : Migration + protected override void Up(MigrationBuilder migrationBuilder) { - private static readonly string[] s_columns = ["Name", "Description"]; - - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_Order_Description", - table: "Order", - column: "Description") - .Annotation("Npgsql:IndexMethod", "GIN") - .Annotation("Npgsql:TsVectorConfig", "english"); + migrationBuilder.CreateIndex( + name: "IX_Order_Description", + table: "Order", + column: "Description") + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); - migrationBuilder.CreateIndex( - name: "IX_Item_Name_Description", - table: "Item", - columns: s_columns) - .Annotation("Npgsql:IndexMethod", "GIN") - .Annotation("Npgsql:TsVectorConfig", "english"); - } + migrationBuilder.CreateIndex( + name: "IX_Item_Name_Description", + table: "Item", + columns: s_columns) + .Annotation("Npgsql:IndexMethod", "GIN") + .Annotation("Npgsql:TsVectorConfig", "english"); + } - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Order_Description", - table: "Order"); + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Order_Description", + table: "Order"); - migrationBuilder.DropIndex( - name: "IX_Item_Name_Description", - table: "Item"); - } + migrationBuilder.DropIndex( + name: "IX_Item_Name_Description", + table: "Item"); } } diff --git a/templates/SimpleWebUi/tests/UnitTests/packages.lock.json b/templates/SimpleWebUi/tests/UnitTests/packages.lock.json index 741b8e46..5b912dcb 100644 --- a/templates/SimpleWebUi/tests/UnitTests/packages.lock.json +++ b/templates/SimpleWebUi/tests/UnitTests/packages.lock.json @@ -4,22 +4,22 @@ "net10.0": { "bunit": { "type": "Direct", - "requested": "[2.7.2, )", - "resolved": "2.7.2", - "contentHash": "Oe8AI7KU1tPNM1VHTtfdfcxbC2bWnrTW9rAcrEEuZ7JlvsY2NZHvWRa/I2PvfAQifTJf0moe+wZqt8OLbJRklw==", + "requested": "[2.8.6, )", + "resolved": "2.8.6", + "contentHash": "kBGHiCrn0LvUGRiDaqLxBewZXAOIIqYlJTcfh/+yaMc5XOzWm2eeuDl2+3ksK3AuLSSnVitmCLu8n1RV9CXpSQ==", "dependencies": { - "AngleSharp": "1.4.0", - "AngleSharp.Css": "1.0.0-beta.157", + "AngleSharp": "1.5.2", + "AngleSharp.Css": "1.0.0-beta.224", "AngleSharp.Diffing": "1.1.1", - "Microsoft.AspNetCore.Components": "10.0.0", - "Microsoft.AspNetCore.Components.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components.Web": "10.0.0", - "Microsoft.AspNetCore.Components.WebAssembly": "10.0.0", - "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.0", - "Microsoft.Extensions.Caching.Memory": "10.0.0", - "Microsoft.Extensions.Localization.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly": "10.0.10", + "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Localization.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" } }, "coverlet.collector": { @@ -85,10 +85,10 @@ }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.0-beta.157", - "contentHash": "yptHmuGt2tzNGCeD7Orh6HRs4j7MH3IUhI+FM640OlmSwWRMd1yjKnicVcNG147IdF/tjOvgqtvpTU0pH6wVpA==", + "resolved": "1.0.0-beta.224", + "contentHash": "BF/hPs8sRjhR+rYkBom7WaMLN0w36Qvnu+WvoHK/gL67MrQOONkkivBTGilvta4sNix5Y8T9XVlZsRI8IU2/vA==", "dependencies": { - "AngleSharp": "[1.0.0, 2.0.0)" + "AngleSharp": "[1.5.0, 2.0.0)" } }, "AngleSharp.Diffing": { @@ -118,84 +118,84 @@ }, "Microsoft.AspNetCore.Authorization": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "8qUwBAtUj9kX8RwqEPGJu3zGM/GlwF2bR9gsxk/+kiSmCB6aDYlYF7E2gM9VOYS9NDF6zxTSEgJxwD9kFbu8Sw==", + "resolved": "10.0.10", + "contentHash": "FxWC2bc788/51CiSiMkKW5gQyqrSSs1991BhphS6ZARMGii+3Qr355Q9OSB/nBpdHVjIopfegAhreZ6UPpO9WA==", "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.0", - "Microsoft.Extensions.Diagnostics": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" + "Microsoft.AspNetCore.Metadata": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.AspNetCore.Components": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "ljLBmYTPkIe/WhOn/y3kpMCJ4A/xg76H/SRm9XkoOBqlM96Ntcbr+WFJqV/0bEqjaUqQYIMITPnJXrVJ242cWQ==", + "resolved": "10.0.10", + "contentHash": "wlCBFU9YxqhUc2kVbl/FCGNFSbRjS312WVk9YRVjs/OcZ+Yqa9QsD7z+UcBzNNAbcPpEM98f/PaaD6lHt5rmIg==", "dependencies": { - "Microsoft.AspNetCore.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components.Analyzers": "10.0.0" + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Analyzers": "10.0.10" } }, "Microsoft.AspNetCore.Components.Analyzers": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5j9dQ/U51yLLKo8hKJOU7uTPppPsjLTua0PFzRotaePcmz7M9W7pM69pBXDEgkBfGD1tp2ufH0zHRHkwg1ikDg==" + "resolved": "10.0.10", + "contentHash": "ZBJobkEDv6yWftw3ycZMkuV9YpvJOBmjWpg2UbQ0Ol4THObhKai0PNl/SGFBmpw03JKCPA63RgplQOA1LQbRDg==" }, "Microsoft.AspNetCore.Components.Authorization": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "z/4yuTmCD1A3x++Dr8s2H3yD6nUGglqxvjsPL65BUynJlEO0ZaOaI0TIQ58+OyU8IAw98eprHlezix8UyRMqKg==", + "resolved": "10.0.10", + "contentHash": "em2xVQkvtn3BHFT3J9pp4K3yDgPnp0S5a+gcGxb6xkjqy+81yDa8XEEaQnkruCVRusFMltORFFmnlrnHa/A90w==", "dependencies": { - "Microsoft.AspNetCore.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components": "10.0.0" + "Microsoft.AspNetCore.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components": "10.0.10" } }, "Microsoft.AspNetCore.Components.Forms": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5kMwH8DqYFfUpyMNv+IzIoDHxR8fHTPKeJeFNAyjY5tMxo3yyM7ezmuYNcVt9QJ+6K2soHQjBnCvFs8yNd3UGQ==", + "resolved": "10.0.10", + "contentHash": "09cKk0/t83Y5DN5ne4J9Y+OiLJEQ+XBFGIoD0UXAnae8yQEkFa/OYbX0bBGkx0k5r5ndta/CMdlMk3lCG81R0g==", "dependencies": { - "Microsoft.AspNetCore.Components": "10.0.0", - "Microsoft.Extensions.Validation": "10.0.0" + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.Extensions.Validation": "10.0.10" } }, "Microsoft.AspNetCore.Components.Web": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "TAv9+cOrU27YPKuHE7AlG2UBWtTWivvcDHmfdN2yngzQFtas+3p2IBDAF6odNfOJiW3Cs1lHwzqLAoNw5Sc59Q==", + "resolved": "10.0.10", + "contentHash": "8+cyStKhSy94Q2L/2zmgk0H1f+GKvBGEvTImT0qdsCqeNbT7ilGDKTIJ1PiugsPtSNLuUEABUqMpA3rE1yAKFg==", "dependencies": { - "Microsoft.AspNetCore.Components": "10.0.0", - "Microsoft.AspNetCore.Components.Forms": "10.0.0", - "Microsoft.Extensions.DependencyInjection": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0", - "Microsoft.JSInterop": "10.0.0" + "Microsoft.AspNetCore.Components": "10.0.10", + "Microsoft.AspNetCore.Components.Forms": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10", + "Microsoft.JSInterop": "10.0.10" } }, "Microsoft.AspNetCore.Components.WebAssembly": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "ens4WMX0PBTHxRDYpS3i//VktXi+24my01qXI0P91q2rDMHcIKRN0/sdE+4kB1ZwJpHec9vYxDmmnw/JOcXCwQ==", + "resolved": "10.0.10", + "contentHash": "0Zib7U6HmGw4Noj/+yJz1YuEJwo3XOV0iLezXDLFsrHTaC8L+KUOR/1+1tX7AAeipDG6jP3gPZOwpirk6G3LYQ==", "dependencies": { - "Microsoft.AspNetCore.Components.Web": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.Configuration.Json": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.JSInterop.WebAssembly": "10.0.0" + "Microsoft.AspNetCore.Components.Web": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.JSInterop.WebAssembly": "10.0.10" } }, "Microsoft.AspNetCore.Components.WebAssembly.Authentication": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "u4cqo7IdawVPAPHDBlyiTmEodGom6m/OnqXnioz71ASA2l7pAebLed5vaErtsGT9Ud0Z1Nudthy+3FBk0BmHjQ==", + "resolved": "10.0.10", + "contentHash": "D2GIsqVJ2+tVDViJmbEF4tc+gxhdAl5xkBAMZHWCJV7Pt+oBkU2omBSRbgVTq3N2OgreVNSp7w+FrZNgu97d5A==", "dependencies": { - "Microsoft.AspNetCore.Components.Authorization": "10.0.0", - "Microsoft.AspNetCore.Components.Web": "10.0.0" + "Microsoft.AspNetCore.Components.Authorization": "10.0.10", + "Microsoft.AspNetCore.Components.Web": "10.0.10" } }, "Microsoft.AspNetCore.Metadata": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "uMXReBNxJKabrdoxHn4LNDTlGQFGDPtntlVvqo/b0HaxJzeDAIMFXJJ64mufJCNRuQU/Pus8ngcTZErvnsh7vg==" + "resolved": "10.0.10", + "contentHash": "nmZYV9OQ69r3EDOj0SW1JLgD4uFHfMNDKb8mk8Mf4y0Enw3AzkMD85zPuAk5FqmEz8PS0WagR/tJCwLg8xpI3Q==" }, "Microsoft.CodeCoverage": { "type": "Transitive", @@ -234,11 +234,11 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Abstractions": { @@ -251,11 +251,11 @@ }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -269,25 +269,25 @@ }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -297,45 +297,45 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ==" + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", @@ -351,8 +351,8 @@ }, "Microsoft.Extensions.Localization.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "+gopxHC5+vWE61Qq2i+aFblxjSiS4UyjUG5kpfXodWbeBVwXPeP66s8uAi0LPAwnhS5jInmYJNRFYUMn+sdtKg==" + "resolved": "10.0.10", + "contentHash": "j1DygDJR1wP3LG3d7a1rTFPUZcPfhMggcmDEGKhK2iLK0TuAIaW9bHiq1f9k650gdsXsANTm71hEg/k9msNnAQ==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", @@ -388,14 +388,14 @@ }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Primitives": { @@ -405,24 +405,24 @@ }, "Microsoft.Extensions.Validation": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "viIKcmaf1akdhx77c+dLkODvhL+HyonHNpBsicndoi47zvbSeHJEWPOk188NnFCh+dCUz7Icld1jPMbS5E2fqw==", + "resolved": "10.0.10", + "contentHash": "d77zBZk+Z4uo6hEuLvMY+yMjLkn2T/vX5c2sB0sdoSxQVZk7qvec6SuORp2/uZp7UchX4hNp+VJm4GsoSE0waA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.JSInterop": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "Qfs4KtmfMUQ/BtD4jdt753kiAa7frm1QVms8UHiFArOokoXBkBOOKE2N5APdt3BlAdTSFJNiuDFfgDzVq/3EZg==" + "resolved": "10.0.10", + "contentHash": "VivWvz1iFR3Ntf/e5/xci6o/MF2tcHiprNkhRlL+gMkBoDjQJCioiO+Gwp/DP8scuzqbp3b9C3wh+zuy4pD4AA==" }, "Microsoft.JSInterop.WebAssembly": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "j0lrmG+7X4OZh75IzOX00nkwAEjXSgt2Ul6kjwmVkAOBzd8JC1Q/yJ/fC4jWcIXe4h22881Qq1o46p/TnSNaRQ==", + "resolved": "10.0.10", + "contentHash": "FjPAGQWbCP85FWY2Wl9opHSsY+u9nZb8Ui6eTVHEMY/RJateOhW78jEtC4QOQ/6slkS1MKyIAF6ZHIrUR1fX/w==", "dependencies": { - "Microsoft.JSInterop": "10.0.0" + "Microsoft.JSInterop": "10.0.10" } }, "Microsoft.NETCore.Platforms": { From 0649409c23bfd76bf42f56519257692ad8dc00d3 Mon Sep 17 00:00:00 2001 From: Giovanni Brunno Previatti Date: Thu, 30 Jul 2026 15:16:54 -0300 Subject: [PATCH 18/18] feat: improve performance of paginated queries by counting total records concurrently --- .../Data/Common/BaseRepository.cs | 30 ++++++++++++------- .../Data/Common/BaseRepository.cs | 30 ++++++++++++------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs index dcd9a284..d895d3f4 100644 --- a/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Full/src/Infrastructure/Data/Common/BaseRepository.cs @@ -123,6 +123,11 @@ await HandleBaseQueryAsync(async dbEntitySet => bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -135,18 +140,18 @@ await HandleBaseQueryAsync(async dbEntitySet => else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); public async Task<(IEnumerable Items, int TotalRecords)> GetAllPaginatedAsync( @@ -162,6 +167,11 @@ await HandleBaseQueryAsync(async dbEntitySet => bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -174,20 +184,18 @@ await HandleBaseQueryAsync(async dbEntitySet => else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (searchByValues != null && searchByValues.Count != 0) foreach (var searchByValue in searchByValues) - query = query.Where(e => - EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") - ); + query = query.Where(e => EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%")); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); } diff --git a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs index 59aedd9b..4ada0ee7 100644 --- a/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs +++ b/templates/Simple/src/Infrastructure/Data/Common/BaseRepository.cs @@ -162,6 +162,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -174,21 +179,19 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (searchByValues != null && searchByValues.Count != 0) foreach (var searchByValue in searchByValues) - query = query.Where(e => - EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%") - ); + query = query.Where(e => EF.Functions.ILike(EF.Property(e, searchByValue.Key), $"%{searchByValue.Value}%")); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); public async Task<(IEnumerable Items, int TotalRecords)> GetAllFullTextSearchPaginatedAsync( @@ -206,6 +209,11 @@ params Expression>[]? includes bool? newContext = null ) where TEntity : DomainEntity => await HandleBaseQueryAsync Items, int TotalRecords)>(async dbEntitySet => { + var totalRecords = _dbContextFactory + .CreateDbContext() + .Set() + .CountAsync(cancellationToken); + var query = dbEntitySet.AsQueryable(); if (predicate != null) @@ -218,17 +226,17 @@ params Expression>[]? includes else query = query.OrderBy(e => e.CreatedAt); - var totalRecords = await query.CountAsync(cancellationToken); - if (!string.IsNullOrWhiteSpace(searchQuery) && !string.IsNullOrWhiteSpace(searchValue)) query = query.Where(e => EF.Functions.ToTsVector(searchLanguage, EF.Property(e, searchQuery)).Matches(searchValue)); - var items = await query + var items = query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(selector) .ToListAsync(cancellationToken); - return (items, totalRecords); + await Task.WhenAll(totalRecords, items); + + return (await items, await totalRecords); }, correlationId, newContext); }