You've already forked Extensions.Configuration.EntityFrameworkCore
Compare commits
9 Commits
d80dd87d66
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c389d3c9 | ||
|
|
1a00c9a567 | ||
|
|
2aead7260b | ||
|
|
b96de13414 | ||
|
|
db64055acb | ||
|
|
8acbab1832 | ||
|
|
7ab61f8ff9 | ||
|
|
0c824f268c | ||
|
|
b7848d71d6 |
@@ -52,22 +52,29 @@ internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extension
|
|||||||
{
|
{
|
||||||
var dbConnection = Initialise();
|
var dbConnection = Initialise();
|
||||||
|
|
||||||
do
|
try
|
||||||
{
|
{
|
||||||
await ListenForNotifications(stoppingToken);
|
await ListenForNotifications(stoppingToken);
|
||||||
|
|
||||||
try
|
do
|
||||||
{
|
{
|
||||||
await dbConnection.WaitAsync(stoppingToken);
|
try
|
||||||
|
{
|
||||||
|
await dbConnection.WaitAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(e, "Exception while waiting for notifications on channel '{channel}'", _options.ChannelName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is not TaskCanceledException)
|
while (ConnectionState.Open == dbConnection.State || await TryReconnect(stoppingToken));
|
||||||
{
|
|
||||||
_logger?.LogWarning(e, "Exception while waiting for notifications on channel '{channel}'", _options.ChannelName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (await IsReconnectionPossible(stoppingToken));
|
|
||||||
|
|
||||||
_logger?.LogWarning("Giving up listening for notifications on channel '{channel}' because reconnection attempts exhausted. Configuration updates from database will no longer occur", _options.ChannelName);
|
_logger?.LogWarning("Giving up listening for notifications on channel '{channel}' because reconnection attempts exhausted. Configuration updates from database will no longer occur", _options.ChannelName);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException e)
|
||||||
|
{
|
||||||
|
_logger?.LogInformation(e, "Exiting due to signal from stopping token");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Npgsql.NpgsqlConnection Initialise()
|
private Npgsql.NpgsqlConnection Initialise()
|
||||||
@@ -76,8 +83,37 @@ internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extension
|
|||||||
dbConnection.Notification += OnNotification;
|
dbConnection.Notification += OnNotification;
|
||||||
|
|
||||||
return dbConnection;
|
return dbConnection;
|
||||||
|
|
||||||
|
void OnNotification(object sender, Npgsql.NpgsqlNotificationEventArgs args)
|
||||||
|
{
|
||||||
|
_logger?.LogTrace("Received notification '{payload}' on channel '{channel}'", args.Payload, args.Channel);
|
||||||
|
if (args.Channel != _options.ChannelName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using (var previousCts = Interlocked.Exchange(ref _cts, new()))
|
||||||
|
{
|
||||||
|
previousCts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<string> keyValue = args.Payload.Split('=', 2); // DEBT: Do split without heap allocation
|
||||||
|
switch (keyValue.Length)
|
||||||
|
{
|
||||||
|
case 2:
|
||||||
|
_configProvider.Set(keyValue[0], keyValue[1]);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
_configProvider.Remove(keyValue[0]);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_logger?.LogWarning("Invalid '{channel}' payload '{payload}'", args.Channel, args.Payload);
|
||||||
|
return; // Don't proceed to debounced reload
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DebouncedProviderReload(); // Intentionally running without awaiting, may be cancelled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task ListenForNotifications(CancellationToken stoppingToken)
|
private async Task ListenForNotifications(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var listenStatement = $"LISTEN {_options.ChannelName}";
|
var listenStatement = $"LISTEN {_options.ChannelName}";
|
||||||
@@ -86,46 +122,18 @@ internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extension
|
|||||||
_logger?.LogInformation("Listening for notifications on channel '{channel}'", _options.ChannelName);
|
_logger?.LogInformation("Listening for notifications on channel '{channel}'", _options.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNotification(object sender, Npgsql.NpgsqlNotificationEventArgs args)
|
|
||||||
{
|
|
||||||
_logger?.LogTrace("Received notification '{payload}' on channel '{channel}'", args.Payload, args.Channel);
|
|
||||||
if (args.Channel != _options.ChannelName)
|
|
||||||
return;
|
|
||||||
|
|
||||||
using (var previousCts = Interlocked.Exchange(ref _cts, new()))
|
|
||||||
{
|
|
||||||
previousCts.Cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
ReadOnlySpan<string> keyValue = args.Payload.Split('=', 2); // DEBT: Do split without heap allocation
|
|
||||||
switch (keyValue.Length)
|
|
||||||
{
|
|
||||||
case 2:
|
|
||||||
_configProvider.Set(keyValue[0], keyValue[1]);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
_configProvider.Remove(keyValue[0]);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
_logger?.LogWarning("Invalid '{channel}' payload '{payload}'", args.Channel, args.Payload);
|
|
||||||
return; // Don't proceed to debounced reload
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = DebouncedProviderReload(); // Intentionally running without awaiting, may be cancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trigger reload of the <see cref="IEntityFrameworkCoreDbSetConfigurationProvider"/> after waiting for a [cancelable] debounce period
|
/// Trigger reload of the <see cref="IEntityFrameworkCoreDbSetConfigurationProvider"/> after waiting for a [cancelable] debounce period
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Does not log <see cref="TaskCanceledException "/> each time debounce task is cancelled, unlike <see cref="SimpleDebouncedProviderReload"/></remarks>
|
/// <remarks>Does not log <see cref="TaskCanceledException "/> each time debounce task is cancelled, unlike <see cref="SimpleDebouncedProviderReload"/></remarks>
|
||||||
private async ValueTask DebouncedProviderReload()
|
private async Task DebouncedProviderReload()
|
||||||
{
|
{
|
||||||
var delayTask = Task.Delay(_options.DebounceInterval);
|
var delayTask = Task.Delay(_options.DebounceInterval);
|
||||||
var cancelableTask = Task.Delay(Timeout.Infinite, _cts.Token);
|
var cancelableTask = Task.Delay(Timeout.Infinite, _cts.Token);
|
||||||
var completedTask = await Task.WhenAny(delayTask, cancelableTask).ConfigureAwait(false);
|
var completedTask = await Task.WhenAny(delayTask, cancelableTask).ConfigureAwait(false);
|
||||||
|
|
||||||
if (completedTask == delayTask) // If the completed task is the delayTask, we reached debounce delay, proceed with reload
|
if (ReferenceEquals(completedTask, delayTask)) // If the completed task is the delayTask, we reached debounce delay, proceed with reload
|
||||||
{
|
{
|
||||||
_configProvider.OnReload();
|
_configProvider.OnReload();
|
||||||
}
|
}
|
||||||
@@ -154,7 +162,7 @@ internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extension
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<bool> IsReconnectionPossible(CancellationToken stoppingToken)
|
private async Task<bool> TryReconnect(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await Task.Delay(_options.InitialReconnectionDelay, stoppingToken);
|
await Task.Delay(_options.InitialReconnectionDelay, stoppingToken);
|
||||||
|
|
||||||
@@ -165,16 +173,21 @@ internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extension
|
|||||||
var backoffTask = Task.Delay(_options.InitialReconnectionDelay * (1 << i), stoppingToken);
|
var backoffTask = Task.Delay(_options.InitialReconnectionDelay * (1 << i), stoppingToken);
|
||||||
var completedTask = await Task.WhenAny(backoffTask, canConnectTask);
|
var completedTask = await Task.WhenAny(backoffTask, canConnectTask);
|
||||||
|
|
||||||
if (completedTask == canConnectTask) // connect finished first
|
if (ReferenceEquals(completedTask, backoffTask)) // backoff finished first
|
||||||
{
|
{
|
||||||
if (await canConnectTask) return true; // if can connect return immediately
|
continue; // Assume connection task has failed
|
||||||
await backoffTask; // connect failed, wait for backoff time before trying again
|
|
||||||
}
|
}
|
||||||
else // backoff finished first, wait for connection task result before decided whether to go around again or return immediately
|
|
||||||
|
// can connect task finished first
|
||||||
|
if (await canConnectTask) // if it finished with success
|
||||||
{
|
{
|
||||||
if (await canConnectTask) return true;
|
await ListenForNotifications(stoppingToken);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await backoffTask; // connect failed, wait for backoff time before trying again
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,17 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageId>RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL</PackageId>
|
||||||
|
<Version>0.1.1</Version>
|
||||||
|
<Authors>Rhys Ickeringill</Authors>
|
||||||
|
<Description>Adds "reload on change" support to RAIC.Extensions.Configuration.EntityFrameworkCore for PostgreSQL</Description>
|
||||||
|
<PackageTags>configuration;entityframeworkcore;postgresql</PackageTags>
|
||||||
|
<RepositoryUrl>https://git.raickeringill.id.au/ickers/Extensions.Configuration.EntityFrameworkCore</RepositoryUrl>
|
||||||
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="[8.0, 11.0)" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="[8.0, 11.0)" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="[8.0, 11.0)" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="[8.0, 11.0)" />
|
||||||
@@ -16,4 +27,8 @@
|
|||||||
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
|
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with s
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
1. No polling!
|
1. No polling!
|
||||||
1. Updates happen in background via worker service (`IHostedService`)
|
1. Updates happen in background via a [hosted service](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services) implementation.
|
||||||
1. Only update settings which change rather than reloading all of them
|
1. Only update settings which change rather than updating them all
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
* .NET 8
|
* .NET 8
|
||||||
@@ -14,8 +14,8 @@ This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with s
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
|
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
|
||||||
* Setting keys must not contain the `=` character (similar to `CommandLineConfigurationProvider` & `EnvironmentVariablesConfigurationProvider`)
|
* Setting keys must not contain the `=` character (similar to `CommandLineConfigurationProvider` & `EnvironmentVariablesConfigurationProvider`)
|
||||||
* Small window of opportunity for updates to be missed during reconnection process
|
* Small window of opportunity for updates to be missed during reconnection process after any network dropouts or other connectivity flakiness
|
||||||
* Consider adding `Keepalive` to your conenction string (https://www.npgsql.org/doc/keepalive.html) if its not already present
|
* Consider adding `Keepalive` [parameter](https://www.npgsql.org/doc/keepalive.html) to your connection string if its not already present
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
* Not tested under load
|
* Not tested under load
|
||||||
@@ -65,7 +65,7 @@ AFTER DELETE ON settings
|
|||||||
FOR EACH ROW EXECUTE FUNCTION notify_setting_remove();
|
FOR EACH ROW EXECUTE FUNCTION notify_setting_remove();
|
||||||
```
|
```
|
||||||
|
|
||||||
Reccommend adding your SQL to the migration which adds the `Settings` table/view (or a new migration if that table/view already exists).
|
Recommend adding your SQL to the migration which adds the `Settings` table/view (or a new migration if that table/view already exists).
|
||||||
|
|
||||||
## Usage Example
|
## Usage Example
|
||||||
|
|
||||||
@@ -84,9 +84,11 @@ public record Setting : ISetting
|
|||||||
public required string Value { get; set; }
|
public required string Value { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>, Setting>
|
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>>, ISettingsDbContextFactory<MyDbContext>
|
||||||
{
|
{
|
||||||
public DbSet<Setting> Settings { get; set; }
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
|
||||||
|
public static MyDbContext Create(DbContextOptions<MyDbContext> options) => new(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -97,11 +99,11 @@ builder.Configuration.AddJsonFile("appsettings.json")
|
|||||||
...
|
...
|
||||||
.AddUserSecrets<Program>(); // or wherever your connection string lives
|
.AddUserSecrets<Program>(); // or wherever your connection string lives
|
||||||
|
|
||||||
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
builder.Configuration.AddDbContext<MyDbContext>(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
...
|
...
|
||||||
// Add the PostgreSQLNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty
|
// Add the PostgreSQLNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty
|
||||||
builder.Services.AddPostgreSQLNotificationConfigurationReloadService<SettingsDbContext>(); // uses default settings, other overrides exist - see code docs
|
builder.Services.AddPostgreSQLNotificationConfigurationReloadService<MyDbContext>(); // uses default settings, other overrides exist - see code docs
|
||||||
|
|
||||||
await builder.Build().RunAsync(); // use config as normal
|
await builder.Build().RunAsync(); // use config as normal
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public static class ServiceCollectionExtensions
|
|||||||
/// <remarks>If your connection string contains a password then this method may not work, please use another overload</remarks>
|
/// <remarks>If your connection string contains a password then this method may not work, please use another overload</remarks>
|
||||||
/// <exception cref="NullReferenceException">If your <typeparamref name="TDbContext"/> does not have a connection string</exception>
|
/// <exception cref="NullReferenceException">If your <typeparamref name="TDbContext"/> does not have a connection string</exception>
|
||||||
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services)
|
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services)
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : DbContext, ISettingsDbContext<IQueryable<TSetting>>
|
||||||
where TSetting : class, ISetting
|
where TSetting : class, ISetting
|
||||||
{
|
{
|
||||||
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
|
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
|
||||||
@@ -49,7 +49,7 @@ public static class ServiceCollectionExtensions
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>The service collection it was called on now with added services</returns>
|
/// <returns>The service collection it was called on now with added services</returns>
|
||||||
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services, Action<Options> configure)
|
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services, Action<Options> configure)
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : DbContext, ISettingsDbContext<IQueryable<TSetting>>
|
||||||
where TSetting : class, ISetting
|
where TSetting : class, ISetting
|
||||||
{
|
{
|
||||||
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
|
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
|
||||||
@@ -61,7 +61,7 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
|
|
||||||
private static OptionsBuilder<Options> AddCoreServices<TDbContext, TSetting>(this IServiceCollection services)
|
private static OptionsBuilder<Options> AddCoreServices<TDbContext, TSetting>(this IServiceCollection services)
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : DbContext, ISettingsDbContext<IQueryable<TSetting>>
|
||||||
where TSetting : class, ISetting
|
where TSetting : class, ISetting
|
||||||
{
|
{
|
||||||
services.AddSingleton(static provider =>
|
services.AddSingleton(static provider =>
|
||||||
|
|||||||
@@ -5,15 +5,29 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageId>RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer</PackageId>
|
||||||
|
<Version>0.1.1</Version>
|
||||||
|
<Authors>Rhys Ickeringill</Authors>
|
||||||
|
<Description>Adds "reload on change" support to RAIC.Extensions.Configuration.EntityFrameworkCore for Microsoft SQL Server</Description>
|
||||||
|
<PackageTags>configuration;entityframeworkcore;mssql</PackageTags>
|
||||||
|
<RepositoryUrl>https://git.raickeringill.id.au/ickers/Extensions.Configuration.EntityFrameworkCore</RepositoryUrl>
|
||||||
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="[6.0, 7.0)" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="[6.0, 7.0)" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="[8.0, 11.0)" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="[8.0, 11.0)" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="[8.0, 11.0)" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="[8.0, 11.0)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
|
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with s
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
1. No polling!
|
1. No polling!
|
||||||
1. Updates happen in background via worker service (`IHostedService`)
|
1. Updates happen in background via a [hosted service](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services) implementation
|
||||||
1. Only update settings which change rather than reloading all of them
|
1. Only update settings which change rather than updating them all
|
||||||
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -17,13 +17,16 @@ This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with s
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
* Won't work with Azure SQL until Microsoft adds/enables Service Broker support
|
* Won't work with Azure SQL until Microsoft adds/enables Service Broker support
|
||||||
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
|
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
|
||||||
|
* Consider adding `ConnectRetryCount` and `ConnectRetryInterval` [parameters](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/whats-new#sqlclient-data-provider) to your connection string if not already present
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
* Not tested under load
|
* Not tested under load
|
||||||
|
* Transient failure detection logic is not well tested given the challenges in reproducing these issues
|
||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
There is a single property which can be configured (cf. the `SqlServerNotificationConfigurationReloaderOptions` POCO)
|
There are two properties which can be configured (cf. the `SqlServerNotificationConfigurationReloaderOptions` POCO)
|
||||||
1. `ConnectionString` - the full connection string for the SQL Server instance
|
1. `ConnectionString` - the full connection string for the SQL Server instance
|
||||||
|
1. `TransientErrors` - the list of `SqlError.Number` values that will be treated as transient if present in a thrown `SqlException`'s `Errors` collection while listening for or processing notifications (ie. reconnection will be attempted if any are present)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
For `SqlServerNotificationConfigurationReloader` to work it requires Change Tracking to be enable on the `Settings` table (and therefore also on the database itself)
|
For `SqlServerNotificationConfigurationReloader` to work it requires Change Tracking to be enable on the `Settings` table (and therefore also on the database itself)
|
||||||
@@ -37,7 +40,7 @@ ALTER TABLE dbo.Settings
|
|||||||
ENABLE CHANGE_TRACKING;
|
ENABLE CHANGE_TRACKING;
|
||||||
```
|
```
|
||||||
|
|
||||||
Reccommend adding your SQL to the migration which adds the `Settings` table/view (or a new migration if that table/view already exists).
|
Recommend adding your SQL to the migration which adds the `Settings` table/view (or a new migration if that table/view already exists).
|
||||||
|
|
||||||
## Usage Example
|
## Usage Example
|
||||||
|
|
||||||
@@ -57,9 +60,11 @@ public record Setting : ISetting
|
|||||||
public required string Value { get; set; }
|
public required string Value { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>,Setting>
|
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>>, ISettingsDbContextFactory<MyDbContext>
|
||||||
{
|
{
|
||||||
public DbSet<Setting> Settings { get; set; }
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
|
||||||
|
public static MyDbContext Create(DbContextOptions<MyDbContext> options) => new(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@ builder.Configuration.AddJsonFile("appsettings.json")
|
|||||||
...
|
...
|
||||||
.AddUserSecrets<Program>(); // or wherever your connection string lives
|
.AddUserSecrets<Program>(); // or wherever your connection string lives
|
||||||
|
|
||||||
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
|
builder.Configuration.AddDbContext<MyDbContext>(dbContextOptions => dbContextOptions.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
...
|
...
|
||||||
// Add the SqlServerNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty
|
// Add the SqlServerNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -14,11 +12,14 @@ public class SqlServerNotificationConfigurationReloaderOptions // must be public
|
|||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public required string ConnectionString { get; set; }
|
public required string ConnectionString { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required HashSet<int> TransientErrors { get; set; } = [2, 53, 121, 10060, 11001];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting> : Microsoft.Extensions.Hosting.BackgroundService
|
internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting> : Microsoft.Extensions.Hosting.BackgroundService
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : DbContext, ISettingsDbContext<IQueryable<TSetting>>
|
||||||
where TSetting : class, ISetting
|
where TSetting : class, ISetting
|
||||||
{
|
{
|
||||||
private static string? _changesQueryTemplate;
|
private static string? _changesQueryTemplate;
|
||||||
@@ -42,35 +43,50 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var settingsQuery = await Initialise(stoppingToken);
|
|
||||||
|
|
||||||
do
|
try
|
||||||
{
|
{
|
||||||
|
var settingsQuery = await Initialise(stoppingToken);
|
||||||
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
var (taskCompletionSource, cancelTokenRegistration) = await ListenForNotifications(settingsQuery, stoppingToken);
|
try
|
||||||
using (cancelTokenRegistration)
|
|
||||||
{
|
{
|
||||||
var lastVersion = await GetChangeTrackingVersion(stoppingToken);
|
do
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await taskCompletionSource.Task;
|
var notificationCompletion = await ListenForNotifications(settingsQuery, stoppingToken);
|
||||||
|
using var tcsCancelRegistration = stoppingToken.Register(() => notificationCompletion.TrySetCanceled());
|
||||||
|
|
||||||
|
var lastVersion = await GetChangeTrackingVersion(stoppingToken);
|
||||||
|
|
||||||
|
await notificationCompletion.Task;
|
||||||
UpdateConfiguration(lastVersion);
|
UpdateConfiguration(lastVersion);
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is not TaskCanceledException)
|
while (true); // each notification is one-and-done, so must keep re-regesitering for notifications indefinitely
|
||||||
{
|
}
|
||||||
_logger?.LogWarning(e, "Exception while listening for notifications on query '{query}'", settingsQuery);
|
catch (SqlException e) when (e.InnerException is SqlException inner && _options.TransientErrors.Overlaps(inner.Errors.OfType<SqlError>().Select(e => e.Number)))
|
||||||
break;
|
{
|
||||||
}
|
_logger?.LogWarning(e, "Transient exception during notification setup process or the processing of notifications");
|
||||||
|
}
|
||||||
|
catch (SqlNotificationException e) // only ever thown while waiting for notification task to complete, must (?) be dropped connection of some sort
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(e, "Transient exception while listening for notifications");
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger?.LogError(e, "Exception while setting up, listening for or processing notifications");
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (true); // each notification is one-and-done, so must keep re-regesitering indefinitely
|
while (await TryReconnect(stoppingToken));
|
||||||
}
|
|
||||||
while (await ReconnectOnDependencyException(stoppingToken));
|
|
||||||
|
|
||||||
_logger?.LogWarning("Giving up listening for notifications on query '{query}' because reconnection failed. Configuration updates from database will no longer occur", settingsQuery);
|
_logger?.LogWarning("Giving up listening for notifications on query '{query}' because reconnection failed. Configuration updates from database will no longer occur", settingsQuery);
|
||||||
SqlDependency.Stop(_options.ConnectionString);
|
SqlDependency.Stop(_options.ConnectionString);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException e)
|
||||||
|
{
|
||||||
|
_logger?.LogInformation(e, "Exiting due to signal from stopping token");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +106,7 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
|
|
||||||
var settingsQuery = _dbContext.Settings.ToQueryString();
|
var settingsQuery = _dbContext.Settings.ToQueryString();
|
||||||
|
|
||||||
_logger?.LogInformation("Listening for notifications on query '{query}'", settingsQuery);
|
_logger?.LogInformation(@"Query upon which notifications will be listened for: ""{query}""", settingsQuery);
|
||||||
|
|
||||||
await openTask;
|
await openTask;
|
||||||
|
|
||||||
@@ -98,8 +114,10 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<(TaskCompletionSource, CancellationTokenRegistration)> ListenForNotifications(string settingsQuery, CancellationToken stoppingToken)
|
private async Task<TaskCompletionSource> ListenForNotifications(string settingsQuery, CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
// Can't reuse the command built here, as once it gets associated with a SqlDependency it can't be used with another,
|
||||||
|
// and SqlDependencies can't be used more than once either... hence there's no point "preparing" the command either
|
||||||
using var command = new SqlCommand()
|
using var command = new SqlCommand()
|
||||||
{
|
{
|
||||||
Connection = (SqlConnection)_dbContext.Database.GetDbConnection(),
|
Connection = (SqlConnection)_dbContext.Database.GetDbConnection(),
|
||||||
@@ -110,22 +128,22 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
var dependency = new SqlDependency(command);
|
var dependency = new SqlDependency(command);
|
||||||
var tcs = new TaskCompletionSource();
|
var tcs = new TaskCompletionSource();
|
||||||
dependency.OnChange += OnChange;
|
dependency.OnChange += OnChange;
|
||||||
var tcsCancelRegistration = stoppingToken.Register(() => tcs.TrySetCanceled());
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await command.ExecuteNonQueryAsync(stoppingToken);
|
await command.ExecuteNonQueryAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is not TaskCanceledException)
|
catch (Exception e) when (e is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger?.LogError(e, "Exception while attempting to register query dependency");
|
_logger?.LogError(e, "Exception while attempting to register query dependency");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (tcs, tcsCancelRegistration);
|
return tcs;
|
||||||
|
|
||||||
void OnChange(object sender, SqlNotificationEventArgs args)
|
void OnChange(object sender, SqlNotificationEventArgs args)
|
||||||
{
|
{
|
||||||
|
dependency.OnChange -= OnChange;
|
||||||
switch (args.Info)
|
switch (args.Info)
|
||||||
{
|
{
|
||||||
case SqlNotificationInfo.Insert:
|
case SqlNotificationInfo.Insert:
|
||||||
@@ -137,7 +155,7 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
return;
|
return;
|
||||||
case SqlNotificationInfo.Error:
|
case SqlNotificationInfo.Error:
|
||||||
_logger?.LogWarning("SqlDependency '{info}' from {type}@{source}", args.Info, args.Type, args.Source);
|
_logger?.LogWarning("SqlDependency '{info}' from {type}@{source}", args.Info, args.Type, args.Source);
|
||||||
tcs.TrySetException(new Exception($"SqlDependency {args.Info} from {args.Type}@{args.Source}"));
|
tcs.TrySetException(new SqlNotificationException($"SqlDependency {args.Info} from {args.Type}@{args.Source}"));
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
_logger?.LogWarning("Ignoring '{info}' from {type}@{source} received from SqlDependency", args.Info, args.Type, args.Source);
|
_logger?.LogWarning("Ignoring '{info}' from {type}@{source} received from SqlDependency", args.Info, args.Type, args.Source);
|
||||||
@@ -150,7 +168,7 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
private async Task<long> GetChangeTrackingVersion(CancellationToken stoppingToken)
|
private async Task<long> GetChangeTrackingVersion(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var query = "SELECT CHANGE_TRACKING_CURRENT_VERSION() as value";
|
var query = "SELECT CHANGE_TRACKING_CURRENT_VERSION() as value";
|
||||||
return await _dbContext.Database.SqlQueryRaw<long>(query).FirstOrDefaultAsync(stoppingToken);
|
return await _dbContext.Database.SqlQueryRaw<long>(query).SingleAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -200,19 +218,15 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<bool> ReconnectOnDependencyException(CancellationToken stoppingToken)
|
private async Task<bool> TryReconnect(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
// explicitly close connection to force subsequent OpenConnectionAsync() to actually try to open the connection instead of it possibly being a no-op
|
// Unlike the postgres version of this there's no retry logic here - SqlClient does its own retries internally so it is not needed (?)
|
||||||
await _dbContext.Database.CloseConnectionAsync();
|
if (await _dbContext.Database.CanConnectAsync(stoppingToken))
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await _dbContext.Database.OpenConnectionAsync(stoppingToken);
|
await _dbContext.Database.OpenConnectionAsync(stoppingToken);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger?.LogWarning(e, "Exception while attempting to reconnect to database. Configuration updates from database will no longer occur");
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,4 +279,15 @@ internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting>
|
|||||||
base.Dispose();
|
base.Dispose();
|
||||||
_onChangeHandler?.Dispose();
|
_onChangeHandler?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class SqlNotificationException : Exception
|
||||||
|
{
|
||||||
|
public SqlNotificationException(string? message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqlNotificationException(string? message, Exception? innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
||||||
@@ -7,26 +6,25 @@ namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
|||||||
|
|
||||||
internal interface IEntityFrameworkCoreDbSetConfigurationProvider : IConfigurationProvider
|
internal interface IEntityFrameworkCoreDbSetConfigurationProvider : IConfigurationProvider
|
||||||
{
|
{
|
||||||
void OnReload();
|
internal void OnReload();
|
||||||
|
|
||||||
bool Remove(string key);
|
internal bool Remove(string key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
internal class EntityFrameworkCoreDbSetConfigurationProvider<TDbContext, TSetting> : ConfigurationProvider, IEntityFrameworkCoreDbSetConfigurationProvider
|
internal class EntityFrameworkCoreDbSetConfigurationProvider<TDbContext> : ConfigurationProvider, IEntityFrameworkCoreDbSetConfigurationProvider
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : ISettingsDbContext<IQueryable<ISetting>>
|
||||||
where TSetting : class, ISetting
|
|
||||||
{
|
{
|
||||||
private readonly IEntityFrameworkCoreDbSetConfigurationSource<TDbContext> _configurationSource;
|
private readonly EntityFrameworkCoreDbSetConfigurationSource<TDbContext> _configurationSource;
|
||||||
|
|
||||||
internal EntityFrameworkCoreDbSetConfigurationProvider(IEntityFrameworkCoreDbSetConfigurationSource<TDbContext> configurationSource) : base()
|
internal EntityFrameworkCoreDbSetConfigurationProvider(EntityFrameworkCoreDbSetConfigurationSource<TDbContext> configurationSource) : base()
|
||||||
{
|
{
|
||||||
_configurationSource = configurationSource;
|
_configurationSource = configurationSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Load()
|
public override void Load()
|
||||||
{
|
{
|
||||||
using var dbContext = _configurationSource.DbContextFactory!.CreateDbContext();
|
using var dbContext = _configurationSource.DbContextFactory();
|
||||||
Data = dbContext.Settings.ToDictionary(s => s.Key, s => (string?)s.Value);
|
Data = dbContext.Settings.ToDictionary(s => s.Key, s => (string?)s.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,19 +33,14 @@ internal class EntityFrameworkCoreDbSetConfigurationProvider<TDbContext, TSettin
|
|||||||
public new void OnReload() => base.OnReload();
|
public new void OnReload() => base.OnReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface IEntityFrameworkCoreDbSetConfigurationSource<TDbContext> where TDbContext : DbContext
|
|
||||||
{
|
|
||||||
internal IDbContextFactory<TDbContext> DbContextFactory { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class EntityFrameworkCoreDbSetConfigurationSource<TDbContext, TSetting> : IConfigurationSource, IEntityFrameworkCoreDbSetConfigurationSource<TDbContext>
|
internal class EntityFrameworkCoreDbSetConfigurationSource<TDbContext> : IConfigurationSource
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : ISettingsDbContext<IQueryable<ISetting>>
|
||||||
where TSetting : class, ISetting
|
|
||||||
{
|
{
|
||||||
public required IDbContextFactory<TDbContext> DbContextFactory { get; init; }
|
public required System.Func<TDbContext> DbContextFactory { get; init; }
|
||||||
|
|
||||||
public IConfigurationProvider Build(IConfigurationBuilder builder)
|
public IConfigurationProvider Build(IConfigurationBuilder builder)
|
||||||
{
|
{
|
||||||
return new EntityFrameworkCoreDbSetConfigurationProvider<TDbContext, TSetting>(this);
|
return new EntityFrameworkCoreDbSetConfigurationProvider<TDbContext>(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
|
||||||
@@ -11,8 +10,7 @@ public static class ConfigurationBuilderExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a <see cref="DbSet{}"/> off <typeparamref name="TDbContext"/> as a configuration provider to the <see cref="IConfigurationBuilder"/>.
|
/// Adds a <see cref="DbSet{}"/> off <typeparamref name="TDbContext"/> as a configuration provider to the <see cref="IConfigurationBuilder"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements <see cref="ISettingsDbContext{,}"/></typeparam>
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements both <see cref="ISettingsDbContext{}"/> and <see cref="ISettingsDbContextFactory{}"/></typeparam>
|
||||||
/// <typeparam name="TSetting">Concrete type which implements <see cref="ISetting"/></typeparam>
|
|
||||||
/// <param name="optionsTransformer">
|
/// <param name="optionsTransformer">
|
||||||
/// a <see cref="DbContextOptionsTransformer{}"/> which configures your <see cref="DbContextOptions{}"/>. eg.
|
/// a <see cref="DbContextOptionsTransformer{}"/> which configures your <see cref="DbContextOptions{}"/>. eg.
|
||||||
/// <code>
|
/// <code>
|
||||||
@@ -20,14 +18,13 @@ public static class ConfigurationBuilderExtensions
|
|||||||
/// </code>
|
/// </code>
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>The <see cref="IConfigurationBuilder"/></returns>
|
/// <returns>The <see cref="IConfigurationBuilder"/></returns>
|
||||||
public static IConfigurationBuilder AddDbSet<TDbContext, TSetting>(this IConfigurationBuilder builder, DbContextOptionsTransformer<TDbContext> optionsTransformer)
|
public static IConfigurationBuilder AddDbContext<TDbContext>(this IConfigurationBuilder builder, DbContextOptionsTransformer<TDbContext> optionsTransformer)
|
||||||
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
where TDbContext : DbContext, ISettingsDbContext<System.Linq.IQueryable<ISetting>>, ISettingsDbContextFactory<TDbContext>
|
||||||
where TSetting : class, ISetting
|
|
||||||
{
|
{
|
||||||
// DEBT: Find way to create non-pooled DbContextFactory since this is only a short lived usage
|
var options = optionsTransformer(new DbContextOptionsBuilder<TDbContext>()).Options;
|
||||||
var configurationSource = new EntityFrameworkCoreDbSetConfigurationSource<TDbContext, TSetting>()
|
var configurationSource = new EntityFrameworkCoreDbSetConfigurationSource<TDbContext>()
|
||||||
{
|
{
|
||||||
DbContextFactory = new PooledDbContextFactory<TDbContext>(optionsTransformer(new DbContextOptionsBuilder<TDbContext>()).Options, poolSize: 1)
|
DbContextFactory = () => TDbContext.Create(options)
|
||||||
};
|
};
|
||||||
|
|
||||||
return builder.Add(configurationSource);
|
return builder.Add(configurationSource);
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
using System;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
||||||
|
|
||||||
public interface ISettingsDbContext<out TSettingDbSet, out TSetting> : IDisposable
|
public interface ISettingsDbContext<out TSettings> : System.IDisposable
|
||||||
where TSettingDbSet : DbSet<TSetting>
|
where TSettings : System.Linq.IQueryable<ISetting>
|
||||||
where TSetting : class, ISetting
|
|
||||||
{
|
{
|
||||||
TSettingDbSet Settings { get; }
|
TSettings Settings { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISettingsDbContextFactory<TDbContext>
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
static abstract TDbContext Create(DbContextOptions<TDbContext> options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageId>RAIC.Extensions.Configuration.EntityFrameworkCore</PackageId>
|
||||||
|
<Version>0.1.1</Version>
|
||||||
|
<Authors>Rhys Ickeringill</Authors>
|
||||||
|
<Description>Entity Framework Core based configuration provider for Microsoft.Extensions.Configuration</Description>
|
||||||
|
<PackageTags>configuration;entityframeworkcore</PackageTags>
|
||||||
|
<RepositoryUrl>https://git.raickeringill.id.au/ickers/Extensions.Configuration.EntityFrameworkCore</RepositoryUrl>
|
||||||
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL" />
|
<InternalsVisibleTo Include="RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL" />
|
||||||
@@ -16,4 +27,8 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="[8.0, 11.0)" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="[8.0, 11.0)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -33,19 +33,22 @@ public record Setting : ISetting
|
|||||||
public required string Value { get; set; }
|
public required string Value { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>, Setting>
|
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>>, ISettingsDbContextFactory<MyDbContext>
|
||||||
{
|
{
|
||||||
public DbSet<Setting> Settings { get; set; }
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
|
||||||
|
public static MyDbContext Create(DbContextOptions<MyDbContext> options) => new(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args) // or WebApplication.CreateBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args) // or WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// build an initial configuration
|
// build an initial configuration
|
||||||
builder.Configuration.AddJsonFile("appsettings.json")
|
builder.Configuration.AddJsonFile("appsettings.json")
|
||||||
|
...
|
||||||
.AddUserSecrets<Program>(); // or whereever your connection string lives
|
.AddUserSecrets<Program>(); // or whereever your connection string lives
|
||||||
|
|
||||||
// obtain connection string from preliminary config so can initialise other settings from DbSet
|
// obtain connection string from preliminary config so can initialise other settings from DbContext
|
||||||
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
builder.Configuration.AddDbContext<MyDbContext>(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user