diff --git a/src/Server/StellarChat.Server.Api/Program.cs b/src/Server/StellarChat.Server.Api/Program.cs index 89dcb56..9de9188 100644 --- a/src/Server/StellarChat.Server.Api/Program.cs +++ b/src/Server/StellarChat.Server.Api/Program.cs @@ -1,6 +1,7 @@ using StellarChat.Shared.Infrastructure.Exceptions; using StellarChat.Shared.Infrastructure.Contexts; using StellarChat.Shared.Infrastructure.Observability.Logging; +using StellarChat.Shared.Infrastructure.DAL.Mongo; var builder = WebApplication.CreateBuilder(args); @@ -11,6 +12,7 @@ builder.Services.AddErrorHandling(); builder.Services.AddContext(); builder.Host.UseLogging(); +builder.Services.AddMongo(builder.Configuration); var app = builder.Build(); diff --git a/src/Shared/StellarChat.Shared.Abstractions/Pagination/IPagedQuery.cs b/src/Shared/StellarChat.Shared.Abstractions/Pagination/IPagedQuery.cs new file mode 100644 index 0000000..50df3e9 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Abstractions/Pagination/IPagedQuery.cs @@ -0,0 +1,9 @@ +namespace StellarChat.Shared.Abstractions.Pagination; + +public interface IPagedQuery +{ + int Page { get; set; } + int PageSize { get; set; } +} + +public interface IPagedQuery<T> : IPagedQuery { } diff --git a/src/Shared/StellarChat.Shared.Abstractions/Pagination/Paged.cs b/src/Shared/StellarChat.Shared.Abstractions/Pagination/Paged.cs new file mode 100644 index 0000000..8ef6986 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Abstractions/Pagination/Paged.cs @@ -0,0 +1,36 @@ +namespace StellarChat.Shared.Abstractions.Pagination; + +public class Paged<T> : PagedBase +{ + public IReadOnlyList<T> Items { get; set; } = Array.Empty<T>(); + public bool Empty => Items is null || !Items.Any(); + public bool HasNextPage => CurrentPage * ResultsPerPage < TotalResults; + public bool HasPreviousPage => CurrentPage > 1; + + public Paged() + { + TotalPages = 1; + } + + public Paged(IReadOnlyList<T> items, + int currentPage, int resultsPerPage, + int totalPages, long totalResults) : + base(currentPage, resultsPerPage, totalPages, totalResults) + { + Items = items; + } + + public static Paged<T> Create(IReadOnlyList<T> items, + int currentPage, int resultsPerPage, + int totalPages, long totalResults) + => new(items, currentPage, resultsPerPage, totalPages, totalResults); + + public static Paged<T> From(PagedBase result, IReadOnlyList<T> items) + => new(items, result.CurrentPage, result.ResultsPerPage, + result.TotalPages, result.TotalResults); + + public static Paged<T> AsEmpty => new(); + + public Paged<TResult> Map<TResult>(Func<T, TResult> map) + => Paged<TResult>.From(this, Items.Select(map).ToList()); +} diff --git a/src/Shared/StellarChat.Shared.Abstractions/Pagination/PagedBase.cs b/src/Shared/StellarChat.Shared.Abstractions/Pagination/PagedBase.cs new file mode 100644 index 0000000..a340f99 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Abstractions/Pagination/PagedBase.cs @@ -0,0 +1,20 @@ +namespace StellarChat.Shared.Abstractions.Pagination; + +public abstract class PagedBase +{ + public int CurrentPage { get; set; } + public int ResultsPerPage { get; set; } + public int TotalPages { get; set; } + public long TotalResults { get; set; } + + protected PagedBase() { } + + protected PagedBase(int currentPage, int resultsPerPage, + int totalPages, long totalResults) + { + CurrentPage = currentPage; + ResultsPerPage = resultsPerPage; + TotalPages = totalPages; + TotalResults = totalResults; + } +} diff --git a/src/Shared/StellarChat.Shared.Abstractions/Pagination/PagedQuery.cs b/src/Shared/StellarChat.Shared.Abstractions/Pagination/PagedQuery.cs new file mode 100644 index 0000000..2895675 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Abstractions/Pagination/PagedQuery.cs @@ -0,0 +1,11 @@ +namespace StellarChat.Shared.Abstractions.Pagination; + +public abstract class PagedQuery : IPagedQuery +{ + public int Page { get; set; } = 0; + public int PageSize { get; set; } + //public string OrderBy { get; set; } = string.Empty; + //public string SortOrder { get; set; } = string.Empty; +} + +public abstract class PagedQuery<T> : PagedQuery, IPagedQuery<Paged<T>> { } diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Extensions.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Extensions.cs new file mode 100644 index 0000000..81a0eca --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Extensions.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Configuration; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Bson.Serialization; +using MongoDB.Bson; +using MongoDB.Driver; +using StellarChat.Shared.Infrastructure.DAL.Mongo.Factories; +using StellarChat.Shared.Infrastructure.DAL.Mongo.Seeders; +using Microsoft.Extensions.DependencyInjection; +using StellarChat.Shared.Infrastructure.DAL.Mongo.Repositories; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +public static class Extensions +{ + public static IServiceCollection AddMongo(this IServiceCollection services, IConfiguration configuration, Type? seederType = null) + { + var section = configuration.GetSection("mongo"); + var options = section.BindOptions<MongoOptions>(); + services.Configure<MongoOptions>(section); + + if (!section.Exists()) + { + return services; + } + + var mongoClient = new MongoClient(options.ConnectionString); + var database = mongoClient.GetDatabase(options.Database); + services.AddSingleton<IMongoClient>(mongoClient); + services.AddSingleton(database); + + services.AddTransient<IMongoSessionFactory, MongoSessionFactory>(); + + if (seederType is null) + { + services.AddTransient<IMongoDbSeeder, MongoDbSeeder>(); + + using var scope = services.BuildServiceProvider().CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService<IMongoDbSeeder>(); + seeder.SeedAsync(database).GetAwaiter().GetResult(); + } + else + { + services.AddTransient(typeof(IMongoDbSeeder), seederType); + } + + RegisterConventions(); + + return services; + } + + public static IServiceCollection AddMongoRepository<TEntity, TIdentifiable>(this IServiceCollection services, string collectionName) + where TEntity : IIdentifiable<TIdentifiable> + { + services.AddTransient<IMongoRepository<TEntity, TIdentifiable>>(sp => + { + var database = sp.GetRequiredService<IMongoDatabase>(); + return new MongoRepository<TEntity, TIdentifiable>(database, collectionName); + }); + + return services; + } + + private static void RegisterConventions() + { + BsonSerializer.RegisterSerializer(typeof(decimal), new DecimalSerializer(BsonType.Decimal128)); + BsonSerializer.RegisterSerializer(typeof(decimal?), + new NullableSerializer<decimal>(new DecimalSerializer(BsonType.Decimal128))); + ConventionRegistry.Register("mongo-conventions", new ConventionPack + { + new CamelCaseElementNameConvention(), + new IgnoreExtraElementsConvention(true), + new EnumRepresentationConvention(BsonType.String), + }, _ => true); + } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Factories/MongoSessionFactory.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Factories/MongoSessionFactory.cs new file mode 100644 index 0000000..bc6009c --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Factories/MongoSessionFactory.cs @@ -0,0 +1,14 @@ +using MongoDB.Driver; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo.Factories; + +internal sealed class MongoSessionFactory : IMongoSessionFactory +{ + private readonly IMongoClient _client; + + public MongoSessionFactory(IMongoClient client) + => _client = client; + + public Task<IClientSessionHandle> CreateAsync() + => _client.StartSessionAsync(); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IAppSettingsSeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IAppSettingsSeeder.cs new file mode 100644 index 0000000..9fb7772 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IAppSettingsSeeder.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +public interface IAppSettingsSeeder +{ + Task SeedAsync(IMongoDatabase database); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IIdentifiable.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IIdentifiable.cs new file mode 100644 index 0000000..df95a40 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IIdentifiable.cs @@ -0,0 +1,6 @@ +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +public interface IIdentifiable<out T> +{ + T Id { get; } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoDbSeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoDbSeeder.cs new file mode 100644 index 0000000..f7aedf0 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoDbSeeder.cs @@ -0,0 +1,9 @@ +using MongoDB.Driver; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo +{ + public interface IMongoDbSeeder + { + Task SeedAsync(IMongoDatabase database); + } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoRepository.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoRepository.cs new file mode 100644 index 0000000..c0530eb --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoRepository.cs @@ -0,0 +1,24 @@ +using MongoDB.Driver; +using StellarChat.Shared.Abstractions.Pagination; +using System.Linq.Expressions; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +public interface IMongoRepository<TDocument, in TIdentifiable> where TDocument : IIdentifiable<TIdentifiable> +{ + IMongoCollection<TDocument> Collection { get; } + IQueryable<TDocument> AsQueryable(); + Task<TDocument> GetAsync(TIdentifiable id); + Task<TDocument> GetAsync(Expression<Func<TDocument, bool>> predicate); + Task<IReadOnlyList<TDocument>> FindAsync(Expression<Func<TDocument, bool>> predicate); + IEnumerable<TProjected> FilterBy<TProjected>(Expression<Func<TDocument, bool>> filterExpression, Expression<Func<TDocument, TProjected>> projectionExpression); + Task<Paged<TDocument>> BrowseAsync<TQuery>(Expression<Func<TDocument, bool>> predicate, TQuery query) where TQuery : IPagedQuery; + Task AddAsync(TDocument document); + Task AddManyAsync(ICollection<TDocument> documents); + Task UpdateAsync(TDocument document); + Task UpdateAsync(TDocument document, Expression<Func<TDocument, bool>> predicate); + Task DeleteAsync(TIdentifiable id); + Task DeleteAsync(Expression<Func<TDocument, bool>> predicate); + Task DeleteManyAsync(Expression<Func<TDocument, bool>> filterExpression); + Task<bool> ExistsAsync(Expression<Func<TDocument, bool>> predicate); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoSessionFactory.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoSessionFactory.cs new file mode 100644 index 0000000..8c5d877 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/IMongoSessionFactory.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +public interface IMongoSessionFactory +{ + Task<IClientSessionHandle> CreateAsync(); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/MongoOptions.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/MongoOptions.cs new file mode 100644 index 0000000..095aa65 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/MongoOptions.cs @@ -0,0 +1,8 @@ +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +internal class MongoOptions +{ + public string ConnectionString { get; set; } = null!; + public string Database { get; set; } = null!; + public bool DisableTransactions { get; set; } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Pagination.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Pagination.cs new file mode 100644 index 0000000..2f8447b --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Pagination.cs @@ -0,0 +1,56 @@ +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using StellarChat.Shared.Abstractions.Pagination; +using StellarChat.Shared.Infrastructure.DAL.Mongo; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo; + +public static class Pagination +{ + public static async Task<Paged<T>> PaginateAsync<T>(this IMongoQueryable<T> collection, IPagedQuery query) + => await collection.PaginateAsync(query.Page, query.PageSize); + + public static async Task<Paged<T>> PaginateAsync<T>(this IMongoQueryable<T> collection, + int page, int resultsPerPage) + { + if (page <= 0) + { + page = 1; + } + if (resultsPerPage <= 0) + { + resultsPerPage = 10; + } + var isEmpty = await collection.AnyAsync() == false; + if (isEmpty) + { + return Paged<T>.AsEmpty; + } + var totalResults = await collection.CountAsync(); + var totalPages = (int)Math.Ceiling((decimal)totalResults / resultsPerPage); + var data = await collection.Limit(page, resultsPerPage).ToListAsync(); + + return Paged<T>.Create(data, page, resultsPerPage, totalPages, totalResults); + } + + public static IMongoQueryable<T> Limit<T>(this IMongoQueryable<T> collection, IPagedQuery query) + => collection.Limit(query.Page, query.PageSize); + + public static IMongoQueryable<T> Limit<T>(this IMongoQueryable<T> collection, + int page, int resultsPerPage) + { + if (page <= 0) + { + page = 1; + } + if (resultsPerPage <= 0) + { + resultsPerPage = 10; + } + var skip = (page - 1) * resultsPerPage; + var data = collection.Skip(skip) + .Take(resultsPerPage); + + return data; + } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Repositories/MongoRepository.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Repositories/MongoRepository.cs new file mode 100644 index 0000000..9c0d41e --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Repositories/MongoRepository.cs @@ -0,0 +1,59 @@ +using System.Linq.Expressions; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using StellarChat.Shared.Abstractions.Pagination; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo.Repositories; + +internal class MongoRepository<TDocument, TIdentifiable> : IMongoRepository<TDocument, TIdentifiable> + where TDocument : IIdentifiable<TIdentifiable> +{ + public MongoRepository(IMongoDatabase database, string collectionName) + { + Collection = database.GetCollection<TDocument>(collectionName); + } + + public IMongoCollection<TDocument> Collection { get; } + + public IQueryable<TDocument> AsQueryable() => Collection.AsQueryable(); + + public Task<TDocument> GetAsync(TIdentifiable id) + => GetAsync(e => e.Id.Equals(id)); + + public Task<TDocument> GetAsync(Expression<Func<TDocument, bool>> predicate) + => Collection.Find(predicate).SingleOrDefaultAsync(); + + public async Task<IReadOnlyList<TDocument>> FindAsync(Expression<Func<TDocument, bool>> predicate) + => await Collection.Find(predicate).ToListAsync(); + + public IEnumerable<TProjected> FilterBy<TProjected>(Expression<Func<TDocument, bool>> filterExpression, Expression<Func<TDocument, TProjected>> projectionExpression) + => Collection.Find(filterExpression).Project(projectionExpression).ToEnumerable(); + + public Task<Paged<TDocument>> BrowseAsync<TQuery>(Expression<Func<TDocument, bool>> predicate, + TQuery query) where TQuery : IPagedQuery + => Collection.AsQueryable().Where(predicate).PaginateAsync(query); + + public Task AddAsync(TDocument document) + => Collection.InsertOneAsync(document); + + public async Task AddManyAsync(ICollection<TDocument> documents) + => await Collection.InsertManyAsync(documents); + + public Task UpdateAsync(TDocument document) + => UpdateAsync(document, e => e.Id.Equals(document.Id)); + + public Task UpdateAsync(TDocument document, Expression<Func<TDocument, bool>> predicate) + => Collection.ReplaceOneAsync(predicate, document); + + public Task DeleteAsync(TIdentifiable id) + => DeleteAsync(e => e.Id.Equals(id)); + + public Task DeleteAsync(Expression<Func<TDocument, bool>> predicate) + => Collection.DeleteOneAsync(predicate); + + public Task<bool> ExistsAsync(Expression<Func<TDocument, bool>> predicate) + => Collection.Find(predicate).AnyAsync(); + + public async Task DeleteManyAsync(Expression<Func<TDocument, bool>> filterExpression) + => await Collection.DeleteManyAsync(filterExpression); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Seeders/MongoDbSeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Seeders/MongoDbSeeder.cs new file mode 100644 index 0000000..105ca75 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/DAL/Mongo/Seeders/MongoDbSeeder.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace StellarChat.Shared.Infrastructure.DAL.Mongo.Seeders; + +internal class MongoDbSeeder : IMongoDbSeeder +{ + private readonly IAppSettingsSeeder _appSettingsSeeder; + private readonly ILogger<MongoDbSeeder> _logger; + + public MongoDbSeeder(IAppSettingsSeeder appSettingsSeeder, ILogger<MongoDbSeeder> logger) + { + _appSettingsSeeder = appSettingsSeeder; + _logger = logger; + } + + public async Task SeedAsync(IMongoDatabase database) + { + await CustomSeedAsync(database); + } + + protected virtual async Task CustomSeedAsync(IMongoDatabase database) + { + _logger.LogInformation("Started seeding the database."); + + await _appSettingsSeeder.SeedAsync(database); + + _logger.LogInformation("Finished seeding the database."); + } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj b/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj index 10f0247..4071906 100644 --- a/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj +++ b/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj @@ -11,6 +11,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="MongoDB.Driver" Version="2.24.0" /> <PackageReference Include="Serilog" Version="4.0.0-dev-02113" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.2-dev-00334" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.1-dev-10377" />