From f142bd57c8f3f3823631a5db6e009271c1e1cd24 Mon Sep 17 00:00:00 2001 From: Greg Cook Date: Wed, 7 Jan 2026 08:19:43 -0700 Subject: [PATCH] Update to .NET 10 --- .github/workflows/build-and-publish.yml | 4 +- .github/workflows/ci-build.yml | 4 +- Bounteous.Data.MySQL.sln | 6 + README.md | 355 +++++++++++++++++- .../Bounteous.Data.MySQL.Tests.csproj | 33 ++ .../MySqlDbContextFactoryTests.cs | 186 +++++++++ .../Bounteous.Data.MySQL.csproj | 14 +- 7 files changed, 589 insertions(+), 13 deletions(-) create mode 100644 src/Bounteous.Data.MySQL.Tests/Bounteous.Data.MySQL.Tests.csproj create mode 100644 src/Bounteous.Data.MySQL.Tests/MySqlDbContextFactoryTests.cs diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f717989..55b088e 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -12,8 +12,8 @@ jobs: with: csproj-path: './src/Bounteous.Data.MySQL/Bounteous.Data.MySQL.csproj' nuget-package-name: 'Bounteous.Data.MySQL' - dotnet-version: '8.0.x' - release-path: 'net8.0' + dotnet-version: '10.0.x' + release-path: 'net10.0' secrets: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} NUGET_SOURCE_URI: ${{ secrets.NUGET_SOURCE_URI }} \ No newline at end of file diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 159ee66..370d45e 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -9,7 +9,7 @@ jobs: ci-build: uses: Bounteous-Inc/bounteous-dotnet-common-workflows/.github/workflows/ci-build.yml@main with: - dotnet-version: '8.0.x' - release-path: 'net8.0' + dotnet-version: '10.0.x' + release-path: 'net10.0' build-configuration: 'Release' artifact-name: 'build-output' \ No newline at end of file diff --git a/Bounteous.Data.MySQL.sln b/Bounteous.Data.MySQL.sln index 996ee2e..4206fa0 100644 --- a/Bounteous.Data.MySQL.sln +++ b/Bounteous.Data.MySQL.sln @@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\build-and-publish.yml = .github\workflows\build-and-publish.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bounteous.Data.MySQL.Tests", "src\Bounteous.Data.MySQL.Tests\Bounteous.Data.MySQL.Tests.csproj", "{F6501D1B-6F2C-45A0-BCF5-15CFC252C882}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -31,6 +33,10 @@ Global {42B44759-E1AB-4D1E-9CD5-6A49766DD9E3}.Debug|Any CPU.Build.0 = Debug|Any CPU {42B44759-E1AB-4D1E-9CD5-6A49766DD9E3}.Release|Any CPU.ActiveCfg = Release|Any CPU {42B44759-E1AB-4D1E-9CD5-6A49766DD9E3}.Release|Any CPU.Build.0 = Release|Any CPU + {F6501D1B-6F2C-45A0-BCF5-15CFC252C882}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6501D1B-6F2C-45A0-BCF5-15CFC252C882}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6501D1B-6F2C-45A0-BCF5-15CFC252C882}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6501D1B-6F2C-45A0-BCF5-15CFC252C882}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1AC18BC1-E8FF-4719-8D20-CB6484BB2678} = {090740F2-87A6-47A3-8262-A7F70474B429} diff --git a/README.md b/README.md index 21f7261..550ed2c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,357 @@ # Bounteous.Data.MySQL +A specialized Entity Framework Core data access library for MySQL databases in .NET 8+ applications. This library extends the base `Bounteous.Data` functionality with MySQL-specific configurations, optimizations, and database provider settings. -This module provides a MySQL-specific DbContext for Entity Framework. +## 📦 Installation -This module is also the future home of other MySQL-specific features relating to Entity Framework. +Install the package via NuGet: + +```bash +dotnet add package Bounteous.Data.MySQL +``` + +Or via Package Manager Console: + +```powershell +Install-Package Bounteous.Data.MySQL +``` + +## 🚀 Quick Start + +### 1. Configure Services + +```csharp +using Bounteous.Data.MySQL; +using Microsoft.Extensions.DependencyInjection; + +public void ConfigureServices(IServiceCollection services) +{ + // Register the module + services.AddModule(); + + // Register your connection string provider + services.AddSingleton(); + + // Register your MySQL DbContext factory + services.AddScoped, MyDbContextFactory>(); +} +``` + +### 2. Create Your MySQL DbContext Factory + +```csharp +using Bounteous.Data.MySQL; +using Microsoft.EntityFrameworkCore; + +public class MyDbContextFactory : MySqlDbContextFactory +{ + public MyDbContextFactory(IConnectionBuilder connectionBuilder, IDbContextObserver observer) + : base(connectionBuilder, observer) + { + } + + protected override MyDbContext Create(DbContextOptions options, IDbContextObserver observer) + { + return new MyDbContext(options, observer); + } +} +``` + +### 3. Configure Connection String Provider + +```csharp +using Bounteous.Data; +using Microsoft.Extensions.Configuration; + +public class MyConnectionStringProvider : IConnectionStringProvider +{ + private readonly IConfiguration _configuration; + + public MyConnectionStringProvider(IConfiguration configuration) + { + _configuration = configuration; + } + + public string ConnectionString => _configuration.GetConnectionString("MySQLConnection") + ?? throw new InvalidOperationException("MySQL connection string not found"); +} +``` + +### 4. Use Your MySQL Context + +```csharp +public class CustomerService +{ + private readonly IDbContextFactory _contextFactory; + + public CustomerService(IDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + public async Task CreateCustomerAsync(string name, string email, Guid userId) + { + using var context = _contextFactory.Create().WithUserId(userId); + + var customer = new Customer + { + Name = name, + Email = email + }; + + context.Customers.Add(customer); + await context.SaveChangesAsync(); + + return customer; + } +} +``` + +## 🏗️ Architecture Overview + +Bounteous.Data.MySQL builds upon the foundation of `Bounteous.Data` and provides MySQL-specific enhancements: + +- **MySQL Provider Integration**: Uses `MySql.EntityFrameworkCore` for optimal MySQL performance +- **Connection Resilience**: Built-in retry policies for MySQL connection failures +- **MySQL-Specific Optimizations**: Configured for MySQL's unique characteristics +- **Naming Conventions**: Supports MySQL naming conventions and best practices +- **Audit Trail Support**: Inherits automatic auditing from base `Bounteous.Data` +- **Soft Delete Support**: Logical deletion capabilities optimized for MySQL + +## 🔧 Key Features + +### MySQL-Specific DbContext Factory + +The `MySqlDbContextFactory` class provides MySQL-optimized configuration: + +```csharp +public abstract class MySqlDbContextFactory : DbContextFactory where T : IDbContext +{ + protected override DbContextOptions ApplyOptions(bool sensitiveDataLoggingEnabled = false) + { + return new DbContextOptionsBuilder() + .UseMySQL(ConnectionBuilder.AdminConnectionString, mySqlOptions => + { + mySqlOptions.EnableRetryOnFailure(); + }) + .EnableSensitiveDataLogging(sensitiveDataLoggingEnabled) + .EnableDetailedErrors() + .Options; + } +} +``` + +**Features:** +- **Retry on Failure**: Automatic retry for transient MySQL connection issues +- **Sensitive Data Logging**: Configurable logging for debugging (disabled in production) +- **Detailed Errors**: Enhanced error reporting for development +- **MySQL Provider**: Uses official MySQL Entity Framework provider + +### Connection Management + +MySQL-specific connection handling with built-in resilience: + +```csharp +// Connection string format for MySQL +"Server=localhost;Database=MyDatabase;Uid=username;Pwd=password;" + +// With additional MySQL-specific options +"Server=localhost;Database=MyDatabase;Uid=username;Pwd=password;CharSet=utf8mb4;SslMode=Required;" +``` + +### MySQL Naming Conventions + +The library includes support for MySQL naming conventions: + +```csharp +// Configure naming conventions in your DbContext +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + base.OnModelCreating(modelBuilder); + + // Apply MySQL naming conventions + modelBuilder.UseSnakeCaseNamingConvention(); +} +``` + +## 📚 Usage Examples + +### Basic CRUD Operations with MySQL + +```csharp +public class ProductService +{ + private readonly IDbContextFactory _contextFactory; + + public ProductService(IDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + public async Task CreateProductAsync(string name, decimal price, Guid userId) + { + using var context = _contextFactory.Create().WithUserId(userId); + + var product = new Product + { + Name = name, + Price = price, + CreatedOn = DateTime.UtcNow + }; + + context.Products.Add(product); + await context.SaveChangesAsync(); + + return product; + } + + public async Task> GetProductsAsync(int page = 1, int size = 50) + { + using var context = _contextFactory.Create(); + + return await context.Products + .Where(p => !p.IsDeleted) + .OrderByDescending(p => p.CreatedOn) + .ToPaginatedListAsync(page, size); + } + + public async Task UpdateProductAsync(Guid productId, string name, decimal price, Guid userId) + { + using var context = _contextFactory.Create().WithUserId(userId); + + var product = await context.Products.FindById(productId); + product.Name = name; + product.Price = price; + + await context.SaveChangesAsync(); + return product; + } +} +``` + +### MySQL-Specific Query Operations + +```csharp +public class OrderService +{ + private readonly IDbContextFactory _contextFactory; + + public OrderService(IDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + public async Task> GetOrdersByDateRangeAsync(DateTime startDate, DateTime endDate) + { + using var context = _contextFactory.Create(); + + return await context.Orders + .Where(o => o.CreatedOn >= startDate && o.CreatedOn <= endDate) + .Where(o => !o.IsDeleted) + .Include(o => o.Customer) + .OrderByDescending(o => o.CreatedOn) + .ToListAsync(); + } + + public async Task GetTotalSalesAsync(DateTime startDate, DateTime endDate) + { + using var context = _contextFactory.Create(); + + return await context.Orders + .Where(o => o.CreatedOn >= startDate && o.CreatedOn <= endDate) + .Where(o => !o.IsDeleted) + .SumAsync(o => o.TotalAmount); + } +} +``` + +### Soft Delete Operations + +```csharp +public async Task DeleteProductAsync(Guid productId, Guid userId) +{ + using var context = _contextFactory.Create().WithUserId(userId); + + var product = await context.Products.FindById(productId); + + // Soft delete - sets IsDeleted = true + product.IsDeleted = true; + + await context.SaveChangesAsync(); +} +``` + +## 🔧 Configuration Options + +### MySQL Connection String Options + +```csharp +// Basic connection string +"Server=localhost;Database=MyDatabase;Uid=username;Pwd=password;" + +// With additional MySQL options +"Server=localhost;Database=MyDatabase;Uid=username;Pwd=password;" + +"CharSet=utf8mb4;" + +"SslMode=Required;" + +"ConnectionTimeout=30;" + +"DefaultCommandTimeout=30;" +``` + +### MySQL-Specific DbContext Configuration + +```csharp +public class MyDbContext : DbContextBase +{ + public MyDbContext(DbContextOptions options, IDbContextObserver observer) + : base(options, observer) + { + } + + public DbSet Products { get; set; } + public DbSet Orders { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure MySQL-specific settings + modelBuilder.Entity() + .Property(p => p.Price) + .HasColumnType("decimal(18,2)"); + + modelBuilder.Entity() + .Property(o => o.TotalAmount) + .HasColumnType("decimal(18,2)"); + } +} +``` + +## 🎯 Target Framework + +- **.NET 8.0** and later + +## 📋 Dependencies + +- **Bounteous.Data** (0.0.6) - Base data access functionality +- **Microsoft.EntityFrameworkCore** (9.0.3) - Entity Framework Core +- **MySql.EntityFrameworkCore** (9.0.0) - MySQL provider for EF Core +- **EntityFrameworkCore.NamingConventions** (8.0.0) - Naming convention support +- **Microsoft.Extensions.Configuration.Abstractions** (9.0.3) - Configuration management + +## 🔗 Related Projects + +- [Bounteous.Data](../Bounteous.Data/) - Base data access library +- [Bounteous.Core](../Bounteous.Core/) - Core utilities and patterns +- [Bounteous.Data.PostgreSQL](../Bounteous.Data.PostgreSQL/) - PostgreSQL-specific implementation + +## 🤝 Contributing + +This library is maintained by Xerris Inc. For contributions, please contact the development team. + +## 📄 License + +See [LICENSE](LICENSE) file for details. + +--- + +*This library provides MySQL-specific enhancements to the Bounteous.Data framework, ensuring optimal performance and compatibility with MySQL databases in enterprise .NET applications.* \ No newline at end of file diff --git a/src/Bounteous.Data.MySQL.Tests/Bounteous.Data.MySQL.Tests.csproj b/src/Bounteous.Data.MySQL.Tests/Bounteous.Data.MySQL.Tests.csproj new file mode 100644 index 0000000..47c5a5e --- /dev/null +++ b/src/Bounteous.Data.MySQL.Tests/Bounteous.Data.MySQL.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Bounteous.Data.MySQL.Tests/MySqlDbContextFactoryTests.cs b/src/Bounteous.Data.MySQL.Tests/MySqlDbContextFactoryTests.cs new file mode 100644 index 0000000..d8039e4 --- /dev/null +++ b/src/Bounteous.Data.MySQL.Tests/MySqlDbContextFactoryTests.cs @@ -0,0 +1,186 @@ +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace Bounteous.Data.MySQL.Tests; + +public class MySqlDbContextFactoryTests +{ + private class TestDbContext : DbContextBase + { + public TestDbContext(DbContextOptions options, IDbContextObserver observer) + : base(options, observer) + { + } + + protected override void RegisterModels(ModelBuilder modelBuilder) + { + } + } + + private class TestMySqlDbContextFactory : MySqlDbContextFactory + { + public TestMySqlDbContextFactory(IConnectionBuilder connectionBuilder, IDbContextObserver observer) + : base(connectionBuilder, observer) + { + } + + protected override TestDbContext Create(DbContextOptions options, IDbContextObserver observer) + { + return new TestDbContext(options, observer); + } + + public DbContextOptions TestApplyOptions(bool sensitiveDataLoggingEnabled = false) + { + return ApplyOptions(sensitiveDataLoggingEnabled); + } + + public TestDbContext TestCreate(DbContextOptions options, IDbContextObserver observer) + { + return Create(options, observer); + } + } + + [Fact] + public void Constructor_WithValidParameters_ShouldCreateInstance() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + + Assert.NotNull(factory); + } + + + [Fact] + public void ApplyOptions_WithDefaultParameters_ShouldReturnOptionsWithMySqlProvider() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + + Assert.NotNull(options); + } + + [Fact] + public void ApplyOptions_WithSensitiveDataLoggingEnabled_ShouldReturnOptionsWithLoggingEnabled() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(sensitiveDataLoggingEnabled: true); + + Assert.NotNull(options); + } + + [Fact] + public void ApplyOptions_WithSensitiveDataLoggingDisabled_ShouldReturnOptionsWithLoggingDisabled() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(sensitiveDataLoggingEnabled: false); + + Assert.NotNull(options); + } + + [Fact] + public void ApplyOptions_ShouldUseAdminConnectionString() + { + const string expectedConnectionString = "Server=testserver;Database=testdb;User=admin;Password=pass;"; + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns(expectedConnectionString); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + + Assert.NotNull(options); + mockConnectionBuilder.Verify(x => x.AdminConnectionString, Times.AtLeastOnce); + } + + [Fact] + public void Create_ShouldReturnValidDbContext() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + var context = factory.TestCreate(options, mockObserver.Object); + + Assert.NotNull(context); + Assert.IsType(context); + } + + [Fact] + public void Create_ShouldUseProvidedObserver() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + var context = factory.TestCreate(options, mockObserver.Object); + + Assert.NotNull(context); + } + + [Theory] + [InlineData("Server=localhost;Database=db1;")] + [InlineData("Server=remotehost;Database=db2;Port=3307;")] + [InlineData("Server=127.0.0.1;Database=testdb;User=root;")] + public void ApplyOptions_WithDifferentConnectionStrings_ShouldHandleCorrectly(string connectionString) + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns(connectionString); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + + Assert.NotNull(options); + mockConnectionBuilder.Verify(x => x.AdminConnectionString, Times.AtLeastOnce); + } + + [Fact] + public void ApplyOptions_ShouldEnableDetailedErrors() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + + Assert.NotNull(options); + } + + [Fact] + public void Create_MultipleCalls_ShouldReturnDifferentInstances() + { + var mockConnectionBuilder = new Mock(); + mockConnectionBuilder.Setup(x => x.AdminConnectionString).Returns("Server=localhost;Database=test;"); + var mockObserver = new Mock(); + + var factory = new TestMySqlDbContextFactory(mockConnectionBuilder.Object, mockObserver.Object); + var options = factory.TestApplyOptions(); + var context1 = factory.TestCreate(options, mockObserver.Object); + var context2 = factory.TestCreate(options, mockObserver.Object); + + Assert.NotNull(context1); + Assert.NotNull(context2); + Assert.NotSame(context1, context2); + } +} diff --git a/src/Bounteous.Data.MySQL/Bounteous.Data.MySQL.csproj b/src/Bounteous.Data.MySQL/Bounteous.Data.MySQL.csproj index f0e5919..3a129b0 100644 --- a/src/Bounteous.Data.MySQL/Bounteous.Data.MySQL.csproj +++ b/src/Bounteous.Data.MySQL/Bounteous.Data.MySQL.csproj @@ -1,23 +1,23 @@  - net8.0 + net10.0 enable enable 0.0.1 - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + +