feat(localdb): options validation + AddZbLocalDb DI; fail-fast on unsupported parameter collections

This commit is contained in:
Joseph Doherty
2026-07-17 21:40:23 -04:00
parent 3d72d2ee4b
commit c061ba733f
4 changed files with 200 additions and 0 deletions
@@ -0,0 +1,42 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb.Internal;
namespace ZB.MOM.WW.LocalDb;
/// <summary>DI registration for the embedded SQLite local database.</summary>
public static class LocalDbServiceCollectionExtensions
{
/// <summary>
/// Binds and validates <see cref="LocalDbOptions"/> from the <c>"LocalDb"</c> configuration section and
/// registers <see cref="ILocalDb"/> as a singleton.
/// </summary>
/// <param name="onReady">
/// Invoked once, on the ILocalDb, immediately after it is constructed and before it is returned to the first
/// caller. This is where consumers create their tables (via the passed <see cref="ILocalDb"/>) and call
/// <see cref="ILocalDb.RegisterReplicated"/> to opt them into replication.
/// </param>
public static IServiceCollection AddZbLocalDb(
this IServiceCollection services,
IConfiguration configuration,
Action<ILocalDb>? onReady = null)
{
services.AddOptions<LocalDbOptions>()
.Bind(configuration.GetSection("LocalDb"))
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<LocalDbOptions>, LocalDbOptionsValidator>());
services.TryAddSingleton<ILocalDb>(sp =>
{
var options = sp.GetRequiredService<IOptions<LocalDbOptions>>().Value;
var db = new SqliteLocalDb(options);
onReady?.Invoke(db);
return db;
});
return services;
}
}
@@ -224,6 +224,15 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
foreach (var (key, value) in dict)
cmd.Parameters.AddWithValue("@" + key, value ?? DBNull.Value);
return;
// Any other collection would silently mis-bind under the reflection fallback (its members —
// Count, Keys, Length … — bind as bogus @parameters), so fail loudly instead. string is exempt:
// it is IEnumerable but a legitimate reflection-fallback target is never a bare string anyway.
case System.Collections.IEnumerable when parameters is not string:
throw new ArgumentException(
"Unsupported parameters type: pass an anonymous object / POCO (public properties bind as @Name) " +
"or an IDictionary<string, object?> (entries bind as @Key). A Dictionary<string, string>, Hashtable, " +
"array, or other collection is not supported.",
nameof(parameters));
default:
foreach (var prop in PropertiesOf(parameters.GetType()))
cmd.Parameters.AddWithValue("@" + prop.Name, prop.GetValue(parameters) ?? DBNull.Value);
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.LocalDb;
/// <summary>
/// Validates <see cref="LocalDbOptions"/> at startup, accumulating every failure into one result so
/// <c>ValidateOnStart</c> reports all problems at once. Mirrors the fail-closed rules the
/// <c>SqliteLocalDb</c> constructor also enforces.
/// </summary>
public sealed class LocalDbOptionsValidator : IValidateOptions<LocalDbOptions>
{
// Interpolated into a PRAGMA statement by the host (pragmas cannot be parameterized), so it is allow-listed.
private static readonly string[] AllowedSynchronous = ["OFF", "NORMAL", "FULL", "EXTRA"];
public ValidateOptionsResult Validate(string? name, LocalDbOptions options)
{
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.Path))
failures.Add("LocalDb:Path is required.");
if (options.BusyTimeoutMs <= 0)
failures.Add($"LocalDb:BusyTimeoutMs must be greater than 0; got {options.BusyTimeoutMs}.");
if (!Array.Exists(AllowedSynchronous,
s => s.Equals(options.Synchronous, StringComparison.OrdinalIgnoreCase)))
failures.Add(
$"LocalDb:Synchronous must be one of {string.Join("/", AllowedSynchronous)}; got '{options.Synchronous}'.");
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}