diff --git a/.gitignore b/.gitignore index e405ae2e..18813eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ tmpgen/ # ReSharper/Rider user settings *.DotSettings.user + +# Local issue tracking +tickerq_open_issues.md diff --git a/src/TickerQ.EntityFrameworkCore/Customizer/TickerModelCustomizer.cs b/src/TickerQ.EntityFrameworkCore/Customizer/TickerModelCustomizer.cs index dfb66b55..3a9964c0 100644 --- a/src/TickerQ.EntityFrameworkCore/Customizer/TickerModelCustomizer.cs +++ b/src/TickerQ.EntityFrameworkCore/Customizer/TickerModelCustomizer.cs @@ -16,7 +16,7 @@ public TickerModelCustomizer(ModelCustomizerDependencies dependencies) public override void Customize(ModelBuilder builder, DbContext context) { - var schema = context.GetService>().Schema; + var schema = context.GetService>()?.Schema ?? Constants.DefaultSchema; builder.ApplyConfiguration(new TimeTickerConfigurations(schema)); builder.ApplyConfiguration(new CronTickerConfigurations(schema)); diff --git a/src/TickerQ.EntityFrameworkCore/DbContextFactory/TickerQDbContext.cs b/src/TickerQ.EntityFrameworkCore/DbContextFactory/TickerQDbContext.cs index 268d773f..7d05ba87 100644 --- a/src/TickerQ.EntityFrameworkCore/DbContextFactory/TickerQDbContext.cs +++ b/src/TickerQ.EntityFrameworkCore/DbContextFactory/TickerQDbContext.cs @@ -23,7 +23,7 @@ protected TickerQDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - var schema = this.GetService>().Schema; + var schema = this.GetService>()?.Schema ?? Constants.DefaultSchema; modelBuilder.ApplyConfiguration(new TimeTickerConfigurations(schema)); modelBuilder.ApplyConfiguration(new CronTickerConfigurations(schema)); diff --git a/tests/TickerQ.EntityFrameworkCore.Tests/Infrastructure/DesignTimeDbContextTests.cs b/tests/TickerQ.EntityFrameworkCore.Tests/Infrastructure/DesignTimeDbContextTests.cs new file mode 100644 index 00000000..c87ae940 --- /dev/null +++ b/tests/TickerQ.EntityFrameworkCore.Tests/Infrastructure/DesignTimeDbContextTests.cs @@ -0,0 +1,148 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using TickerQ.EntityFrameworkCore.Customizer; +using TickerQ.EntityFrameworkCore.DbContextFactory; +using TickerQ.Utilities.Entities; + +namespace TickerQ.EntityFrameworkCore.Tests.Infrastructure; + +/// +/// Tests that TickerQDbContext and TickerModelCustomizer work at design-time +/// when TickerQEfCoreOptionBuilder is not available in the service provider. +/// Covers issue #457: design-time migrations fail with NullReferenceException. +/// +public class DesignTimeDbContextTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public DesignTimeDbContextTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + } + + public void Dispose() => _connection.Dispose(); + + #region TickerQDbContext — OnModelCreating without DI + + [Fact] + public void OnModelCreating_WithoutOptionBuilder_UsesDefaultSchema() + { + // Simulate design-time: create DbContext without registering TickerQEfCoreOptionBuilder + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var context = new TickerQDbContext(options); + + // OnModelCreating should not throw — it should fall back to Constants.DefaultSchema + var model = context.Model; + + // Verify the tables use the default "ticker" schema + var timeTickerEntity = model.FindEntityType(typeof(TimeTickerEntity)); + Assert.NotNull(timeTickerEntity); + Assert.Equal("ticker", timeTickerEntity.GetSchema()); + + var cronTickerEntity = model.FindEntityType(typeof(CronTickerEntity)); + Assert.NotNull(cronTickerEntity); + Assert.Equal("ticker", cronTickerEntity.GetSchema()); + } + + [Fact] + public void OnModelCreating_WithOptionBuilder_UsesConfiguredSchema() + { + // Simulate runtime: TickerQEfCoreOptionBuilder is available with custom schema + var optionBuilder = new TickerQEfCoreOptionBuilder(); + optionBuilder.SetSchema("custom_schema"); + + var services = new ServiceCollection(); + services.AddSingleton(optionBuilder); + var serviceProvider = services.BuildServiceProvider(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .UseApplicationServiceProvider(serviceProvider) + .Options; + + using var context = new TickerQDbContext(options); + var model = context.Model; + + var timeTickerEntity = model.FindEntityType(typeof(TimeTickerEntity)); + Assert.NotNull(timeTickerEntity); + Assert.Equal("custom_schema", timeTickerEntity.GetSchema()); + } + + [Fact] + public void OnModelCreating_WithoutOptionBuilder_CanCreateDatabase() + { + // Verify the full design-time flow: create context → ensure created (simulates migration) + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var context = new TickerQDbContext(options); + + // This exercises the full model building + DDL generation path + var created = context.Database.EnsureCreated(); + Assert.True(created); + } + + #endregion + + #region TickerModelCustomizer — Customize without DI + + [Fact] + public void TickerModelCustomizer_WithoutOptionBuilder_UsesDefaultSchema() + { + // Simulate design-time with UseApplicationDbContext path: + // TickerModelCustomizer replaces IModelCustomizer, but TickerQEfCoreOptionBuilder is missing + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .ReplaceService>() + .Options; + + using var context = new CustomAppDbContext(options); + var model = context.Model; + + // TickerQ entities should be configured with default schema + var timeTickerEntity = model.FindEntityType(typeof(TimeTickerEntity)); + Assert.NotNull(timeTickerEntity); + Assert.Equal("ticker", timeTickerEntity.GetSchema()); + } + + [Fact] + public void TickerModelCustomizer_WithOptionBuilder_UsesConfiguredSchema() + { + var optionBuilder = new TickerQEfCoreOptionBuilder(); + optionBuilder.SetSchema("app_schema"); + + var services = new ServiceCollection(); + services.AddSingleton(optionBuilder); + var serviceProvider = services.BuildServiceProvider(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .ReplaceService>() + .UseApplicationServiceProvider(serviceProvider) + .Options; + + using var context = new CustomAppDbContext(options); + var model = context.Model; + + var timeTickerEntity = model.FindEntityType(typeof(TimeTickerEntity)); + Assert.NotNull(timeTickerEntity); + Assert.Equal("app_schema", timeTickerEntity.GetSchema()); + } + + #endregion +} + +/// +/// Simulates a user's application DbContext that uses UseApplicationDbContext path. +/// +public class CustomAppDbContext : DbContext +{ + public CustomAppDbContext(DbContextOptions options) : base(options) { } +}