You've already forked Extensions.Configuration.EntityFrameworkCore
Working implementation
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.PostgreSQLNotificationConfigurationReloaderOptions;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
|
||||||
|
public delegate OptionsBuilder<T> OptionsTransformer<T>(OptionsBuilder<T> transform) where T : class;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="PostgreSQLNotificationConfigurationReloader"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
|
||||||
|
/// and supporting services to the <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> to use to obtain the underlying database connection</typeparam>
|
||||||
|
/// <param name="services">The service collection to add the services too</param>
|
||||||
|
/// <returns>The service collection it was called on now with added services</returns>
|
||||||
|
public static IServiceCollection AddPostgreSQLNotificationConfigurationReloadService<TDbContext>(this IServiceCollection services)
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
return services.AddPostgreSQLNotificationConfigurationReloadService<TDbContext>(static _ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="PostgreSQLNotificationConfigurationReloader"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
|
||||||
|
/// and supporting services to the <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> to use to obtain the underlying database connection</typeparam>
|
||||||
|
/// <param name="services">The service collection to add the services too</param>
|
||||||
|
/// <param name="configure">
|
||||||
|
/// Action to manually configure the <see cref="Options"/> instance consumed by <see cref="PostgreSQLNotificationConfigurationReloader"/> eg.
|
||||||
|
/// <code>
|
||||||
|
/// options => {
|
||||||
|
/// options.DebounceInterval = TimeSpan.FromSeconds(2);
|
||||||
|
/// options.ChannelName = "awesome_channel";
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The service collection it was called on now with added services</returns>
|
||||||
|
public static IServiceCollection AddPostgreSQLNotificationConfigurationReloadService<TDbContext>(this IServiceCollection services, Action<Options> configure)
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
var optionsBuilder = services.AddCoreServices<TDbContext>();
|
||||||
|
|
||||||
|
optionsBuilder.Configure(configure);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="PostgreSQLNotificationConfigurationReloader"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
|
||||||
|
/// and supporting services to the <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> to use to obtain the underlying database connection</typeparam>
|
||||||
|
/// <param name="services">The service collection to add the services too</param>
|
||||||
|
/// <param name="optionsTransform">
|
||||||
|
/// Transformer delegate to apply to the <see cref="Options"/> instance consumed by <see cref="PostgreSQLNotificationConfigurationReloader"/> eg.
|
||||||
|
/// <code>
|
||||||
|
/// optionsBuilder => optionsBuilder.Bind(context.Configuration)
|
||||||
|
/// </code>
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The service collection it was called on now with added services</returns>
|
||||||
|
public static IServiceCollection AddPostgreSQLNotificationConfigurationReloadService<TDbContext>(this IServiceCollection services, OptionsTransformer<Options> optionsTransform)
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
var optionsBuilder = services.AddCoreServices<TDbContext>();
|
||||||
|
|
||||||
|
optionsTransform(optionsBuilder);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static OptionsBuilder<Options> AddCoreServices<TDbContext>(this IServiceCollection services)
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
services.AddSingleton(static provider => provider.GetRequiredService<TDbContext>().Database)
|
||||||
|
.AddSingleton(static provider =>
|
||||||
|
{
|
||||||
|
var configRoot = (IConfigurationRoot)provider.GetRequiredService<IConfiguration>(); // DEBT: Is this cast always safe?
|
||||||
|
return configRoot.Providers.OfType<IEntityFrameworkCoreDbSetConfigurationProvider>().Single();
|
||||||
|
})
|
||||||
|
.AddHostedService<PostgreSQLNotificationConfigurationReloader>();
|
||||||
|
|
||||||
|
return services.AddOptions<Options>().ValidateDataAnnotations().ValidateOnStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
CREATE OR REPLACE FUNCTION settings_poc.notify_setting_change()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('setting_channel', concat(NEW.key,'=',NEW.value));
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION settings_poc.notify_setting_remove()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('setting_channel', OLD.key);
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_insert_trigger
|
||||||
|
AFTER INSERT ON settings_poc.settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION settings_poc.notify_setting_change();
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_update_trigger
|
||||||
|
AFTER UPDATE ON settings_poc.settings
|
||||||
|
FOR EACH ROW WHEN (NEW.value <> OLD.value) EXECUTE FUNCTION settings_poc.notify_setting_change();
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_delete_trigger
|
||||||
|
AFTER DELETE ON settings_poc.settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION settings_poc.notify_setting_remove();
|
||||||
|
|
||||||
|
*
|
||||||
|
*/
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.PostgreSQLNotificationConfigurationReloaderOptions;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL;
|
||||||
|
|
||||||
|
public class PostgreSQLNotificationConfigurationReloaderOptions // must be public because it appears in public method signatures (various ServiceCollectionExtension methods)
|
||||||
|
{
|
||||||
|
private const string DefaultChannelName = "settings_channel";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public TimeSpan DebounceInterval { get; set; } = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string ChannelName { get; set; } = DefaultChannelName;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public byte MaxReconnectionAttempts { get; set; } = 5;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public TimeSpan InitialReconnectionDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extensions.Hosting.BackgroundService
|
||||||
|
{
|
||||||
|
private readonly DatabaseFacade _db;
|
||||||
|
private readonly IEntityFrameworkCoreDbSetConfigurationProvider _configProvider;
|
||||||
|
private readonly IDisposable? _onChangeHandler;
|
||||||
|
private readonly ILogger? _logger;
|
||||||
|
|
||||||
|
private CancellationTokenSource _cts = new();
|
||||||
|
private Options _options;
|
||||||
|
|
||||||
|
|
||||||
|
public PostgreSQLNotificationConfigurationReloader(DatabaseFacade dbFacade, IEntityFrameworkCoreDbSetConfigurationProvider configProvider, IOptionsMonitor<Options> options, ILogger<PostgreSQLNotificationConfigurationReloader>? logger = null)
|
||||||
|
{
|
||||||
|
_db = dbFacade;
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_options = options.CurrentValue;
|
||||||
|
_onChangeHandler = options.OnChange(opt => _options = opt);
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var dbConnection = Initialise();
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
await ListenForNotifications(stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dbConnection.WaitAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not TaskCanceledException)
|
||||||
|
{
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Npgsql.NpgsqlConnection Initialise()
|
||||||
|
{
|
||||||
|
var dbConnection = (Npgsql.NpgsqlConnection)_db.GetDbConnection();
|
||||||
|
dbConnection.Notification += OnNotification;
|
||||||
|
|
||||||
|
return dbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenForNotifications(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var listenStatement = $"LISTEN {_options.ChannelName}";
|
||||||
|
await _db.OpenConnectionAsync(stoppingToken);
|
||||||
|
await _db.ExecuteSqlRawAsync(listenStatement, stoppingToken);
|
||||||
|
_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>
|
||||||
|
/// Trigger reload of the <see cref="IEntityFrameworkCoreDbSetConfigurationProvider"/> after waiting for a [cancelable] debounce period
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Does not log <see cref="TaskCanceledException "/> each time debounce task is cancelled, unlike <see cref="SimpleDebouncedProviderReload"/></remarks>
|
||||||
|
private async ValueTask DebouncedProviderReload()
|
||||||
|
{
|
||||||
|
var delayTask = Task.Delay(_options.DebounceInterval);
|
||||||
|
var cancelableTask = Task.Delay(Timeout.Infinite, _cts.Token);
|
||||||
|
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
|
||||||
|
{
|
||||||
|
_configProvider.OnReload();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger?.LogTrace("Debounce operation canceled due to new notification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trigger reload of the <see cref="IEntityFrameworkCoreDbSetConfigurationProvider"/> after waiting for a [cancelable] debounce period
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Logs <see cref="TaskCanceledException "/> each time debounce task is cancelled</remarks>
|
||||||
|
private async ValueTask SimpleDebouncedProviderReload()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(_options.DebounceInterval, _cts.Token);
|
||||||
|
_configProvider.OnReload();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger?.LogTrace("Debounce operation canceled due to new notification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async Task<bool> IsReconnectionPossible(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await Task.Delay(_options.InitialReconnectionDelay, stoppingToken);
|
||||||
|
|
||||||
|
for (var i = 1; i <= _options.MaxReconnectionAttempts; ++i)
|
||||||
|
{
|
||||||
|
_logger?.LogInformation("Reconnection check #{i}", i);
|
||||||
|
var canConnectTask = _db.CanConnectAsync(stoppingToken);
|
||||||
|
var backoffTask = Task.Delay(_options.InitialReconnectionDelay * (1 << i), stoppingToken);
|
||||||
|
var completedTask = await Task.WhenAny(backoffTask, canConnectTask);
|
||||||
|
|
||||||
|
if (completedTask == canConnectTask) // connect finished first
|
||||||
|
{
|
||||||
|
if (await canConnectTask) return true; // if can connect return immediately
|
||||||
|
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
|
||||||
|
{
|
||||||
|
if (await canConnectTask) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
base.Dispose();
|
||||||
|
_cts.Cancel(); // following implementation in base class which doesn't Dispose() its _stoppingCts, just calls Cancel()
|
||||||
|
_onChangeHandler?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL
|
||||||
|
|
||||||
|
This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with support for reload on update via PostgreSQL's `LISTEN`/`NOTIFY`
|
||||||
|
[capabilities](https://www.postgresql.org/docs/current/sql-listen.html) as exposed via [npgsql](https://www.npgsql.org/doc/wait.html).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
1. No polling!
|
||||||
|
1. Updates happen in background via worker service (`IHostedService`)
|
||||||
|
1. Only update settings which change rather than reloading all of them
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
* .NET 8
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
|
||||||
|
* Setting keys must not contain the `=` character (similar to `CommandLineConfigurationProvider` & `EnvironmentVariablesConfigurationProvider`)
|
||||||
|
* Small window of opportunity for updates to be missed during reconnection process
|
||||||
|
* Consider adding `Keepalive` to your conenction string (https://www.npgsql.org/doc/keepalive.html) if its not already present
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
* Not tested under load
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
There are four properties which can be configured (cf. the `PostgreSQLNotificationConfigurationBackgroundServiceOptions` POCO)
|
||||||
|
1. `ChannelName` - the name of the notification channel to listen to for updates
|
||||||
|
1. `DebounceInterval` - the time interval to wait after a notification before signaling the `IEntityFrameworkCoreDbSetConfigurationProvider` to reload.
|
||||||
|
1. `MaxReconnectionAttempts` - Maximum number of times to retry connecting after a connection dropout (eg. PostgreSQL restart, network connectivity issue)
|
||||||
|
1. `InitialReconnectionDelay` - How long to wait after connection dropout before attempting to reconnection.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
For `PostgreSQLNotificationConfigurationReloader` to work it requires triggers on the `Settings` table to be established which send notifications to the channel
|
||||||
|
corresponding to that specified by the `ChannelName` option (defaults to `settings_channel`). SQL similar to below is one (but not only) way this can be acheived:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION notify_setting_change()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('settings_channel', concat(NEW.key,'=',NEW.value));
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_setting_remove()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('settings_channel', OLD.key);
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_insert_trigger
|
||||||
|
AFTER INSERT ON settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_setting_change();
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_update_trigger
|
||||||
|
AFTER UPDATE ON settings
|
||||||
|
FOR EACH ROW WHEN (NEW.value <> OLD.value) EXECUTE FUNCTION notify_setting_change();
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_key_update_trigger
|
||||||
|
AFTER UPDATE ON settings
|
||||||
|
FOR EACH ROW WHEN (NEW.key <> OLD.key) EXECUTE FUNCTION notify_setting_remove();
|
||||||
|
|
||||||
|
CREATE TRIGGER setting_delete_trigger
|
||||||
|
AFTER DELETE ON settings
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore;
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.Extensions;
|
||||||
|
|
||||||
|
public record Setting : ISetting
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public required string Key { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>, Setting>
|
||||||
|
{
|
||||||
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args); // or WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// build an initial configuration
|
||||||
|
builder.Configuration.AddJsonFile("appsettings.json")
|
||||||
|
...
|
||||||
|
.AddUserSecrets<Program>(); // or wherever your connection string lives
|
||||||
|
|
||||||
|
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
|
...
|
||||||
|
// 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
|
||||||
|
|
||||||
|
await builder.Build().RunAsync(); // use config as normal
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Read more about [Configuration](https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration) and [Options](https://docs.microsoft.com/en-us/dotnet/core/extensions/options) on the Microsoft Docs site.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.SqlServerNotificationConfigurationReloaderOptions;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="SqlServerNotificationConfigurationReloader{,}"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
|
||||||
|
/// and supporting services to the <see cref="IServiceCollection"/>, obtaining its connection string from a <typeparamref name="TDbContext"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements <see cref="ISettingsDbContext{TSettingDbSet, TSetting}"/></typeparam>
|
||||||
|
/// <typeparam name="TSetting">Concrete type which implements <see cref="ISetting"/></typeparam>
|
||||||
|
/// <param name="services">The service collection to add the services too</param>
|
||||||
|
/// <returns>The service collection it was called on now with added services</returns>
|
||||||
|
/// <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>
|
||||||
|
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services)
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
|
||||||
|
|
||||||
|
optionsBuilder.Configure<TDbContext>((options, dependency) =>
|
||||||
|
{
|
||||||
|
options.ConnectionString = dependency.Database.GetConnectionString() ?? throw new NullReferenceException($"{typeof(TDbContext).Name} ConnectionString is null");
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="SqlServerNotificationConfigurationReloader{,}"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
|
||||||
|
/// and supporting services to the <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements <see cref="ISettingsDbContext{,}"/></typeparam>
|
||||||
|
/// <typeparam name="TSetting">Concrete type which implements <see cref="ISetting"/></typeparam>
|
||||||
|
/// <param name="services">The service collection to add the services too</param>
|
||||||
|
/// <param name="configure">
|
||||||
|
/// Action to manually configure the <see cref="Options"/> instance consumed by <see cref="SqlServerNotificationConfigurationReloader{,}"/> eg.
|
||||||
|
/// <code>
|
||||||
|
/// options => {
|
||||||
|
/// options.ConnectionString = context.Configuration.GetConnectionString("Default");
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
/// </param>
|
||||||
|
/// <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)
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
|
||||||
|
|
||||||
|
optionsBuilder.Configure(configure);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static OptionsBuilder<Options> AddCoreServices<TDbContext, TSetting>(this IServiceCollection services)
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
services.AddSingleton(static provider =>
|
||||||
|
{
|
||||||
|
var configRoot = (IConfigurationRoot)provider.GetRequiredService<IConfiguration>(); // DEBT: Is this cast always safe?
|
||||||
|
return configRoot.Providers.OfType<IEntityFrameworkCoreDbSetConfigurationProvider>().Single();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services.AddHostedService<SqlServerNotificationConfigurationReloader<TDbContext, TSetting>>()
|
||||||
|
.AddOptions<Options>().ValidateDataAnnotations().ValidateOnStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer
|
||||||
|
|
||||||
|
This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with support for reload on update the via the
|
||||||
|
[query change notifications](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/sql/query-notifications-in-sql-server) capability exposed by
|
||||||
|
`Microsoft.Data.SqlClient.SqlDependency` along with SQL Server's
|
||||||
|
[Change Tracking](https://learn.microsoft.com/en-us/sql/relational-databases/track-changes/about-change-tracking-sql-server) feature.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
1. No polling!
|
||||||
|
1. Updates happen in background via worker service (`IHostedService`)
|
||||||
|
1. Only update settings which change rather than reloading all of them
|
||||||
|
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
* .NET 8
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
* 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`)
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
* Not tested under load
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
There is a single property which can be configured (cf. the `SqlServerNotificationConfigurationReloaderOptions` POCO)
|
||||||
|
1. `ConnectionString` - the full connection string for the SQL Server instance
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
For `SqlServerNotificationConfigurationReloader` to work it requires Change Tracking to be enable on the `Settings` table (and therefore also on the database itself)
|
||||||
|
eg:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER DATABASE myDatabase
|
||||||
|
SET CHANGE_TRACKING = ON (AUTO_CLEANUP = ON);
|
||||||
|
|
||||||
|
ALTER TABLE dbo.Settings
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore;
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.Extensions;
|
||||||
|
|
||||||
|
[Table("settings", Schema = "dbo")] // Need to explicitly set schema somehow, this is one way to do it
|
||||||
|
public record Setting : ISetting
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public required string Key { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>,Setting>
|
||||||
|
{
|
||||||
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args); // or WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// build an initial configuration
|
||||||
|
builder.Configuration.AddJsonFile("appsettings.json")
|
||||||
|
...
|
||||||
|
.AddUserSecrets<Program>(); // or wherever your connection string lives
|
||||||
|
|
||||||
|
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
|
...
|
||||||
|
// Add the SqlServerNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty
|
||||||
|
builder.Services.AddSqlServerNotificationConfigurationReloadService<MyDbContext, Settings>(); // uses connection string from MyDbContext. Other overrides exist if this doesn't work for you - see cods docs
|
||||||
|
|
||||||
|
await builder.Build().RunAsync(); // use config as normal
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Read more about [Configuration](https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration) and [Options](https://docs.microsoft.com/en-us/dotnet/core/extensions/options) on the Microsoft Docs site.
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.SqlServerNotificationConfigurationReloaderOptions;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer;
|
||||||
|
|
||||||
|
public class SqlServerNotificationConfigurationReloaderOptions // must be public because it appears in public method signatures (various ServiceCollectionExtension methods)
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string ConnectionString { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting> : Microsoft.Extensions.Hosting.BackgroundService
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
private static string? _changesQueryTemplate;
|
||||||
|
|
||||||
|
private readonly TDbContext _dbContext;
|
||||||
|
private readonly IEntityFrameworkCoreDbSetConfigurationProvider _configProvider;
|
||||||
|
private readonly IDisposable? _onChangeHandler;
|
||||||
|
private readonly ILogger? _logger;
|
||||||
|
|
||||||
|
private Options _options;
|
||||||
|
|
||||||
|
public SqlServerNotificationConfigurationReloader(TDbContext dbContext, IEntityFrameworkCoreDbSetConfigurationProvider configProvider, IOptionsMonitor<Options> options, ILogger<SqlServerNotificationConfigurationReloader<TDbContext, TSetting>>? logger = null)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_options = options.CurrentValue;
|
||||||
|
_onChangeHandler = options.OnChange(opt => _options = opt);
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var settingsQuery = await Initialise(stoppingToken);
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var (taskCompletionSource, cancelTokenRegistration) = await ListenForNotifications(settingsQuery, stoppingToken);
|
||||||
|
using (cancelTokenRegistration)
|
||||||
|
{
|
||||||
|
var lastVersion = await GetChangeTrackingVersion(stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await taskCompletionSource.Task;
|
||||||
|
UpdateConfiguration(lastVersion);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not TaskCanceledException)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(e, "Exception while listening for notifications on query '{query}'", settingsQuery);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (true); // each notification is one-and-done, so must keep re-regesitering indefinitely
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
SqlDependency.Stop(_options.ConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async Task<string> Initialise(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var openTask = _dbContext.Database.OpenConnectionAsync(stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SqlDependency.Start(_options.ConnectionString);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger?.LogError(e, "Exception while attempting to start dependency change listener");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsQuery = _dbContext.Settings.ToQueryString();
|
||||||
|
|
||||||
|
_logger?.LogInformation("Listening for notifications on query '{query}'", settingsQuery);
|
||||||
|
|
||||||
|
await openTask;
|
||||||
|
|
||||||
|
return settingsQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async Task<(TaskCompletionSource, CancellationTokenRegistration)> ListenForNotifications(string settingsQuery, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var command = new SqlCommand()
|
||||||
|
{
|
||||||
|
Connection = (SqlConnection)_dbContext.Database.GetDbConnection(),
|
||||||
|
CommandType = CommandType.Text,
|
||||||
|
CommandText = settingsQuery,
|
||||||
|
};
|
||||||
|
|
||||||
|
var dependency = new SqlDependency(command);
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
dependency.OnChange += OnChange;
|
||||||
|
var tcsCancelRegistration = stoppingToken.Register(() => tcs.TrySetCanceled());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await command.ExecuteNonQueryAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not TaskCanceledException)
|
||||||
|
{
|
||||||
|
_logger?.LogError(e, "Exception while attempting to register query dependency");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (tcs, tcsCancelRegistration);
|
||||||
|
|
||||||
|
void OnChange(object sender, SqlNotificationEventArgs args)
|
||||||
|
{
|
||||||
|
switch (args.Info)
|
||||||
|
{
|
||||||
|
case SqlNotificationInfo.Insert:
|
||||||
|
case SqlNotificationInfo.Update:
|
||||||
|
case SqlNotificationInfo.Delete:
|
||||||
|
case SqlNotificationInfo.Expired:
|
||||||
|
case SqlNotificationInfo.Truncate:
|
||||||
|
tcs.TrySetResult();
|
||||||
|
return;
|
||||||
|
case SqlNotificationInfo.Error:
|
||||||
|
_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}"));
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
_logger?.LogWarning("Ignoring '{info}' from {type}@{source} received from SqlDependency", args.Info, args.Type, args.Source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async Task<long> GetChangeTrackingVersion(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var query = "SELECT CHANGE_TRACKING_CURRENT_VERSION() as value";
|
||||||
|
return await _dbContext.Database.SqlQueryRaw<long>(query).FirstOrDefaultAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void UpdateConfiguration(long lastVersion)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
foreach (var (key, value) in GetChanges(lastVersion))
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
_configProvider.Remove(key);
|
||||||
|
}
|
||||||
|
else if (!_configProvider.TryGet(key, out var oldValue) || !string.Equals(oldValue, value))
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
_configProvider.Set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) _configProvider.OnReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private IQueryable<(string key, string? value)> GetChanges(long lastVersion)
|
||||||
|
{
|
||||||
|
_changesQueryTemplate ??= GetQueryTemplate();
|
||||||
|
|
||||||
|
return _dbContext.Database.SqlQueryRaw<TSetting>(_changesQueryTemplate, lastVersion)
|
||||||
|
.Select(s => ValueTuple.Create(s.Key, string.Equals(s.Value, null) ? null : s.Value)); // hack to get EF to not throw if Value is null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private string GetQueryTemplate()
|
||||||
|
{
|
||||||
|
var entityType = _dbContext.Model.FindEntityType(typeof(TSetting))!;
|
||||||
|
var schemaQualifiedTableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]";
|
||||||
|
var keyColumn = $"[{entityType.FindPrimaryKey()!.Properties.Single().GetColumnName()}]";
|
||||||
|
var valueColumn = $"[{entityType.FindProperty(nameof(ISetting.Value))!.GetColumnName()}]";
|
||||||
|
|
||||||
|
var changesQueryTemplate = $$"""
|
||||||
|
SELECT ct.{{keyColumn}}, s.{{valueColumn}}
|
||||||
|
FROM CHANGETABLE( CHANGES {{schemaQualifiedTableName}}, {0} ) AS ct
|
||||||
|
LEFT JOIN {{schemaQualifiedTableName}} AS s ON ct.{{keyColumn}} = s.{{keyColumn}}
|
||||||
|
""";
|
||||||
|
return changesQueryTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async Task<bool> ReconnectOnDependencyException(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// explicitly close connection to force subsequent OpenConnectionAsync() to actually try to open the connection instead of it possibly being a no-op
|
||||||
|
await _dbContext.Database.CloseConnectionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dbContext.Database.OpenConnectionAsync(stoppingToken);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Query here is throwing exception for some reason. Think it is getting confused about key column being aliased as value perhaps?
|
||||||
|
From the logs:
|
||||||
|
warn: RelationalEventId.QueryPossibleUnintendedUseOfEqualsWarning[20501] (Microsoft.EntityFrameworkCore.Query)
|
||||||
|
Possible unintended use of method 'Equals' for arguments 's.Value' and 's0.Key' of different types in a query.This comparison will always return false.
|
||||||
|
...
|
||||||
|
Generated query execution expression:
|
||||||
|
'queryContext => SingleQueryingEnumerable.Create<ValueTuple<string, string>>(
|
||||||
|
relationalQueryContext: (RelationalQueryContext)queryContext,
|
||||||
|
relationalCommandResolver: parameters => [LIFTABLE Constant: RelationalCommandCache.QueryExpression(
|
||||||
|
Client Projections:
|
||||||
|
0 -> 0
|
||||||
|
1 -> 1
|
||||||
|
SELECT s.Value, CASE
|
||||||
|
WHEN s0.Value == NULL THEN NULL
|
||||||
|
ELSE s0.Value
|
||||||
|
END
|
||||||
|
FROM SELECT [Key] AS Value FROM CHANGETABLE( CHANGES [dbo].[Settings], {0} )
|
||||||
|
LEFT JOIN dbo.Settings AS s0 ON CAST(0 AS bit)) | Resolver: c => new RelationalCommandCache(
|
||||||
|
|
||||||
|
|
||||||
|
What's up with that JOIN .. ON clause?!
|
||||||
|
|
||||||
|
*/
|
||||||
|
private IQueryable<(string key, string? value)> GetChangesNotWorking(long lastVersion)
|
||||||
|
{
|
||||||
|
var entityType = _dbContext.Model.FindEntityType(typeof(TSetting))!;
|
||||||
|
var schemaQualifiedTableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]";
|
||||||
|
var keyColumn = $"[{entityType.FindPrimaryKey()!.Properties.Single().GetColumnName()}]";
|
||||||
|
|
||||||
|
var changesQueryTemplate = $"SELECT {keyColumn} AS Value FROM CHANGETABLE( CHANGES {schemaQualifiedTableName}, {{0}} )";
|
||||||
|
|
||||||
|
var query = from changedKey in _dbContext.Database.SqlQueryRaw<string>(changesQueryTemplate, lastVersion)
|
||||||
|
join s in _dbContext.Settings
|
||||||
|
on changedKey equals s.Key into joined
|
||||||
|
from j in joined.DefaultIfEmpty()
|
||||||
|
select ValueTuple.Create(changedKey, string.Equals(j.Value, null) ? null : j.Value);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
base.Dispose();
|
||||||
|
_onChangeHandler?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
||||||
|
|
||||||
|
|
||||||
|
internal interface IEntityFrameworkCoreDbSetConfigurationProvider : IConfigurationProvider
|
||||||
|
{
|
||||||
|
void OnReload();
|
||||||
|
|
||||||
|
bool Remove(string key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
internal class EntityFrameworkCoreDbSetConfigurationProvider<TDbContext, TSetting> : ConfigurationProvider, IEntityFrameworkCoreDbSetConfigurationProvider
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
private readonly IEntityFrameworkCoreDbSetConfigurationSource<TDbContext> _configurationSource;
|
||||||
|
|
||||||
|
internal EntityFrameworkCoreDbSetConfigurationProvider(IEntityFrameworkCoreDbSetConfigurationSource<TDbContext> configurationSource) : base()
|
||||||
|
{
|
||||||
|
_configurationSource = configurationSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Load()
|
||||||
|
{
|
||||||
|
using var dbContext = _configurationSource.DbContextFactory!.CreateDbContext();
|
||||||
|
Data = dbContext.Settings.ToDictionary(s => s.Key, s => (string?)s.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Remove(string key) => Data.Remove(key);
|
||||||
|
|
||||||
|
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>
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
public required IDbContextFactory<TDbContext> DbContextFactory { get; init; }
|
||||||
|
|
||||||
|
public IConfigurationProvider Build(IConfigurationBuilder builder)
|
||||||
|
{
|
||||||
|
return new EntityFrameworkCoreDbSetConfigurationProvider<TDbContext, TSetting>(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
|
||||||
|
|
||||||
|
public static class ConfigurationBuilderExtensions
|
||||||
|
{
|
||||||
|
public delegate DbContextOptionsBuilder<T> DbContextOptionsTransformer<T>(DbContextOptionsBuilder<T> transform) where T : DbContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a <see cref="DbSet{}"/> off <typeparamref name="TDbContext"/> as a configuration provider to the <see cref="IConfigurationBuilder"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements <see cref="ISettingsDbContext{,}"/></typeparam>
|
||||||
|
/// <typeparam name="TSetting">Concrete type which implements <see cref="ISetting"/></typeparam>
|
||||||
|
/// <param name="optionsTransformer">
|
||||||
|
/// a <see cref="DbContextOptionsTransformer{}"/> which configures your <see cref="DbContextOptions{}"/>. eg.
|
||||||
|
/// <code>
|
||||||
|
/// dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default"))
|
||||||
|
/// </code>
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The <see cref="IConfigurationBuilder"/></returns>
|
||||||
|
public static IConfigurationBuilder AddDbSet<TDbContext, TSetting>(this IConfigurationBuilder builder, DbContextOptionsTransformer<TDbContext> optionsTransformer)
|
||||||
|
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
// DEBT: Find way to create non-pooled DbContextFactory since this is only a short lived usage
|
||||||
|
var configurationSource = new EntityFrameworkCoreDbSetConfigurationSource<TDbContext, TSetting>()
|
||||||
|
{
|
||||||
|
DbContextFactory = new PooledDbContextFactory<TDbContext>(optionsTransformer(new DbContextOptionsBuilder<TDbContext>()).Options, poolSize: 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
return builder.Add(configurationSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace RAIC.Extensions.Configuration.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public interface ISettingsDbContext<out TSettingDbSet, out TSetting> : IDisposable
|
||||||
|
where TSettingDbSet : DbSet<TSetting>
|
||||||
|
where TSetting : class, ISetting
|
||||||
|
{
|
||||||
|
TSettingDbSet Settings { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public interface ISetting
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
string Key { get; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
string Value { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
<InternalsVisibleTo Include="RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
56
RAIC.Extensions.Configuration.EntityFrameworkCore/README.md
Normal file
56
RAIC.Extensions.Configuration.EntityFrameworkCore/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# RAIC.Extensions.Configuration.EntityFrameworkCore
|
||||||
|
|
||||||
|
This library is a `Microsoft.Extensions.Configuration.IConfigurationProvider` that reads settings from a `DbSet<ISetting> Settings` property
|
||||||
|
present on your Entity Framework Core `DbContext`.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
1. Usable with minimal constraints on your entity model
|
||||||
|
1. Follows conventional configuration patterns
|
||||||
|
1. `IOptionsMonitor` update support through optional opt-in services (see `RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL` & `RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer`)
|
||||||
|
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
* .NET 8
|
||||||
|
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
|
||||||
|
* Setting keys should not contain the `=` character (similar to `CommandLineConfigurationProvider` & `EnvironmentVariablesConfigurationProvider`)
|
||||||
|
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
using RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
|
||||||
|
|
||||||
|
public record Setting : ISetting
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public required string Key { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>, Setting>
|
||||||
|
{
|
||||||
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args) // or WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// build an initial configuration
|
||||||
|
builder.Configuration.AddJsonFile("appsettings.json")
|
||||||
|
.AddUserSecrets<Program>(); // or whereever your connection string lives
|
||||||
|
|
||||||
|
// obtain connection string from preliminary config so can initialise other settings from DbSet
|
||||||
|
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
await builder.Build().RunAsync(); // use config as normal
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Read more about [Configuration](https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration) on the Microsoft Docs site.
|
||||||
6
README.md
Normal file
6
README.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# RAIC.Extensions.Configuration.EntityFrameworkCore
|
||||||
|
|
||||||
|
Contains source code for the following packages
|
||||||
|
* `RAIC.Extensions.Configuration.EntityFrameworkCore`
|
||||||
|
* `RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL`
|
||||||
|
* `RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer`
|
||||||
Reference in New Issue
Block a user