diff --git a/src/Application/Abstractions/Data/IDbConnectionFactory.cs b/src/Application/Abstractions/Data/IDbConnectionFactory.cs index 45ad648..f0843a7 100644 --- a/src/Application/Abstractions/Data/IDbConnectionFactory.cs +++ b/src/Application/Abstractions/Data/IDbConnectionFactory.cs @@ -1,8 +1,8 @@ -using System.Data; +using System.Data.Common; namespace Application.Abstractions.Data; public interface IDbConnectionFactory { - IDbConnection GetOpenConnection(); + ValueTask OpenConnectionAsync(); } diff --git a/src/Application/Abstractions/Models/PaginatedList.cs b/src/Application/Abstractions/Models/PaginatedList.cs new file mode 100644 index 0000000..deaac98 --- /dev/null +++ b/src/Application/Abstractions/Models/PaginatedList.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; + +namespace Application.Abstractions.Models; +public class PaginatedList +{ + public IReadOnlyCollection Items { get; } + public int PageNumber { get; } + public int TotalPages { get; } + public int TotalCount { get; } + + public PaginatedList(IReadOnlyCollection items, int count, int pageNumber, int pageSize) + { + PageNumber = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + TotalCount = count; + Items = items; + } + + public bool HasPreviousPage => PageNumber > 1; + + public bool HasNextPage => PageNumber < TotalPages; + + public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) + { +#pragma warning disable IDE0008 // Use explicit type + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); +#pragma warning restore IDE0008 // Use explicit type + + return new PaginatedList(items, count, pageNumber, pageSize); + } +} diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index f08cdcc..df7f574 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Application/Categories/Get/CategoryResponse.cs b/src/Application/Categories/Get/CategoryResponse.cs new file mode 100644 index 0000000..60fcf04 --- /dev/null +++ b/src/Application/Categories/Get/CategoryResponse.cs @@ -0,0 +1,2 @@ +namespace Application.Categories.Get; +public sealed record CategoryResponse(Guid Id, string Name); diff --git a/src/Application/Categories/Get/GetCategoryQuery.cs b/src/Application/Categories/Get/GetCategoryQuery.cs new file mode 100644 index 0000000..89a4b5a --- /dev/null +++ b/src/Application/Categories/Get/GetCategoryQuery.cs @@ -0,0 +1,4 @@ +using Application.Abstractions.Messaging; + +namespace Application.Categories.Get; +public sealed record GetCategoryQuery(Guid CategoryId) : IQuery; diff --git a/src/Application/Categories/Get/GetCategoryQueryHandler.cs b/src/Application/Categories/Get/GetCategoryQueryHandler.cs new file mode 100644 index 0000000..70c48f7 --- /dev/null +++ b/src/Application/Categories/Get/GetCategoryQueryHandler.cs @@ -0,0 +1,34 @@ +using System.Data.Common; +using Application.Abstractions.Data; +using Application.Abstractions.Messaging; +using Dapper; +using Domain.Categories; +using SharedKernel; + +namespace Application.Categories.Get; +internal sealed class GetCategoryQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler +{ + public async Task> Handle(GetCategoryQuery request, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + const string sql = + $""" + SELECT + id AS {nameof(CategoryResponse.Id)}, + name AS {nameof(CategoryResponse.Name)} + FROM categories + WHERE id = @CategoryId + """; + + CategoryResponse? category = await connection.QuerySingleOrDefaultAsync(sql, request); + + if (category is null) + { + return Result.Failure(CategoryErrors.NotFound(request.CategoryId)); + } + + return category; + } +} diff --git a/src/Application/Categories/GetAll/GetCategoriesQuery.cs b/src/Application/Categories/GetAll/GetCategoriesQuery.cs new file mode 100644 index 0000000..84c6644 --- /dev/null +++ b/src/Application/Categories/GetAll/GetCategoriesQuery.cs @@ -0,0 +1,6 @@ +using Application.Abstractions.Messaging; +using Application.Abstractions.Models; +using Application.Categories.Get; + +namespace Application.Categories.GetAll; +public sealed record GetCategoriesQuery(int PageNumber, int PageSize) : IQuery>; diff --git a/src/Application/Categories/GetAll/GetCategoriesQueryHandler.cs b/src/Application/Categories/GetAll/GetCategoriesQueryHandler.cs new file mode 100644 index 0000000..d5f5ce3 --- /dev/null +++ b/src/Application/Categories/GetAll/GetCategoriesQueryHandler.cs @@ -0,0 +1,44 @@ +using System.Data.Common; +using Application.Abstractions.Data; +using Application.Abstractions.Messaging; +using Application.Abstractions.Models; +using Application.Categories.Get; +using Dapper; +using SharedKernel; + +namespace Application.Categories.GetAll; +internal sealed class GetCategoriesQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler> +{ + public async Task>> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + const string countSql = @" + SELECT COUNT(*) + FROM categories + "; + + const string sql = @" + SELECT + id AS Id, + name AS Name + FROM categories + ORDER BY id + OFFSET @Offset ROWS + FETCH NEXT @PageSize ROWS ONLY + "; + + int totalCount = await connection.ExecuteScalarAsync(countSql); + +#pragma warning disable IDE0008 // Use explicit type +#pragma warning disable IDE0037 // Use inferred member name + var categories = await connection.QueryAsync(sql, new { Offset = (request.PageNumber - 1) * request.PageSize, PageSize = request.PageSize }); +#pragma warning restore IDE0037 // Use inferred member name +#pragma warning restore IDE0008 // Use explicit type + + var paginatedCategories = new PaginatedList(categories.ToList(), totalCount, request.PageNumber, request.PageSize); + + return Result.Success(paginatedCategories); + } +} diff --git a/src/Application/Categories/GetById/CategoryResponse.cs b/src/Application/Categories/GetById/CategoryResponse.cs deleted file mode 100644 index b8858b9..0000000 --- a/src/Application/Categories/GetById/CategoryResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Application.Categories.GetById; -public sealed record CategoryResponse -{ - public Guid Id { get; init; } - - public string Name { get; init; } -} diff --git a/src/Application/Categories/GetById/GetCategoryByIdQuery.cs b/src/Application/Categories/GetById/GetCategoryByIdQuery.cs deleted file mode 100644 index 22a804c..0000000 --- a/src/Application/Categories/GetById/GetCategoryByIdQuery.cs +++ /dev/null @@ -1,4 +0,0 @@ -using Application.Abstractions.Messaging; - -namespace Application.Categories.GetById; -public sealed record GetCategoryByIdQuery(Guid CategoryId) : IQuery; diff --git a/src/Application/Users/GetByEmail/GetUserByEmailQueryHandler.cs b/src/Application/Users/GetByEmail/GetUserByEmailQueryHandler.cs index b97c251..df815cd 100644 --- a/src/Application/Users/GetByEmail/GetUserByEmailQueryHandler.cs +++ b/src/Application/Users/GetByEmail/GetUserByEmailQueryHandler.cs @@ -1,4 +1,4 @@ -using System.Data; +using System.Data.Common; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Dapper; @@ -23,7 +23,7 @@ FROM users u WHERE u.id = @Email """; - using IDbConnection connection = factory.GetOpenConnection(); + await using DbConnection connection = await factory.OpenConnectionAsync(); UserResponse? user = await connection.QueryFirstOrDefaultAsync( sql, diff --git a/src/Application/Users/GetById/GetUserByIdQueryHandler.cs b/src/Application/Users/GetById/GetUserByIdQueryHandler.cs index 684c04b..a9203d5 100644 --- a/src/Application/Users/GetById/GetUserByIdQueryHandler.cs +++ b/src/Application/Users/GetById/GetUserByIdQueryHandler.cs @@ -1,4 +1,4 @@ -using System.Data; +using System.Data.Common; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Dapper; @@ -23,7 +23,7 @@ FROM users u WHERE u.id = @UserId """; - using IDbConnection connection = factory.GetOpenConnection(); + await using DbConnection connection = await factory.OpenConnectionAsync(); UserResponse? user = await connection.QueryFirstOrDefaultAsync( sql, diff --git a/src/Infrastructure/Database/DbConnectionFactory.cs b/src/Infrastructure/Database/DbConnectionFactory.cs index b9aeb11..14bcfd8 100644 --- a/src/Infrastructure/Database/DbConnectionFactory.cs +++ b/src/Infrastructure/Database/DbConnectionFactory.cs @@ -1,4 +1,4 @@ -using System.Data; +using System.Data.Common; using Application.Abstractions.Data; using Npgsql; @@ -6,10 +6,9 @@ namespace Infrastructure.Database; internal sealed class DbConnectionFactory(NpgsqlDataSource dataSource) : IDbConnectionFactory { - public IDbConnection GetOpenConnection() + public async ValueTask OpenConnectionAsync() { - NpgsqlConnection connection = dataSource.OpenConnection(); - - return connection; + return await dataSource.OpenConnectionAsync(); } } + diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 9b65bc5..1d7b99a 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -70,8 +70,8 @@ private static IServiceCollection AddDatabase(this IServiceCollection services, options => options .UseNpgsql(connectionString, npgsqlOptions => npgsqlOptions.MigrationsHistoryTable(HistoryRepository.DefaultTableName, Schemas.Default)) - .UseSnakeCaseNamingConvention()); - //.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)); + .UseSnakeCaseNamingConvention() + .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/src/Infrastructure/Migrations/20240622151536_InitialCreate.Designer.cs b/src/Infrastructure/Migrations/20240623122729_InitialCreate.Designer.cs similarity index 99% rename from src/Infrastructure/Migrations/20240622151536_InitialCreate.Designer.cs rename to src/Infrastructure/Migrations/20240623122729_InitialCreate.Designer.cs index 24e764c..dac6718 100644 --- a/src/Infrastructure/Migrations/20240622151536_InitialCreate.Designer.cs +++ b/src/Infrastructure/Migrations/20240623122729_InitialCreate.Designer.cs @@ -13,7 +13,7 @@ namespace Infrastructure.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20240622151536_InitialCreate")] + [Migration("20240623122729_InitialCreate")] partial class InitialCreate { /// diff --git a/src/Infrastructure/Migrations/20240622151536_InitialCreate.cs b/src/Infrastructure/Migrations/20240623122729_InitialCreate.cs similarity index 100% rename from src/Infrastructure/Migrations/20240622151536_InitialCreate.cs rename to src/Infrastructure/Migrations/20240623122729_InitialCreate.cs diff --git a/src/Infrastructure/Queries/Categories/GetCategoryByIdQueryHandler.cs b/src/Infrastructure/Queries/Categories/GetCategoryByIdQueryHandler.cs deleted file mode 100644 index 1025d73..0000000 --- a/src/Infrastructure/Queries/Categories/GetCategoryByIdQueryHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Application.Abstractions.Messaging; -using Application.Categories.GetById; -using Domain.Categories; -using Infrastructure.Database; -using Microsoft.EntityFrameworkCore; -using SharedKernel; - -namespace Infrastructure.Queries.Categories; -internal sealed class GetCategoryByIdQueryHandler(ApplicationDbContext context) - : IQueryHandler -{ - public async Task> Handle(GetCategoryByIdQuery request, CancellationToken cancellationToken) - { - CategoryResponse? category = await context.Categories - .Where(c => c.Id == request.CategoryId) - .Select(c => new CategoryResponse - { - Id = c.Id, - Name = c.Name.Value - }) - .FirstOrDefaultAsync(cancellationToken); - - if (category is null) - { - return Result.Failure(CategoryErrors.NotFound(request.CategoryId)); - } - - return category; - } -} diff --git a/src/Web.Api/Endpoints/Categories/Get.cs b/src/Web.Api/Endpoints/Categories/Get.cs index 2891526..b88632d 100644 --- a/src/Web.Api/Endpoints/Categories/Get.cs +++ b/src/Web.Api/Endpoints/Categories/Get.cs @@ -1,4 +1,4 @@ -using Application.Categories.GetById; +using Application.Categories.Get; using MediatR; using SharedKernel; using Web.Api.Extensions; @@ -15,11 +15,11 @@ public void MapEndpoint(IEndpointRouteBuilder app) ISender sender, CancellationToken cancellationToken) => { - var query = new GetCategoryByIdQuery(categoryId); + var query = new GetCategoryQuery(categoryId); - Result result = await sender.Send(query, cancellationToken); + Result result = await sender.Send(query, cancellationToken); - return result.Match(Results.NoContent, CustomResults.Problem); + return result.Match(Results.Ok, CustomResults.Problem); }) .WithTags(Tags.Categories); } diff --git a/src/Web.Api/Endpoints/Categories/GetAll.cs b/src/Web.Api/Endpoints/Categories/GetAll.cs new file mode 100644 index 0000000..f73a593 --- /dev/null +++ b/src/Web.Api/Endpoints/Categories/GetAll.cs @@ -0,0 +1,29 @@ +using Application.Abstractions.Models; +using Application.Categories.Get; +using Application.Categories.GetAll; +using MediatR; +using SharedKernel; +using Web.Api.Extensions; +using Web.Api.Infrastructure; + +namespace Web.Api.Endpoints.Categories; + +internal sealed class GetAll : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("categories", async ( + int pageNumber, + int pageSize, + ISender sender, + CancellationToken cancellationToken) => + { + var query = new GetCategoriesQuery(pageNumber, pageSize); + + Result> result = await sender.Send(query, cancellationToken); + + return result.Match(Results.Ok, CustomResults.Problem); + }) + .WithTags(Tags.Categories); + } +}