feat(localdb): options validation + AddZbLocalDb DI; fail-fast on unsupported parameter collections
This commit is contained in:
+42
@@ -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)
|
foreach (var (key, value) in dict)
|
||||||
cmd.Parameters.AddWithValue("@" + key, value ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@" + key, value ?? DBNull.Value);
|
||||||
return;
|
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:
|
default:
|
||||||
foreach (var prop in PropertiesOf(parameters.GetType()))
|
foreach (var prop in PropertiesOf(parameters.GetType()))
|
||||||
cmd.Parameters.AddWithValue("@" + prop.Name, prop.GetValue(parameters) ?? DBNull.Value);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.LocalDb.Tests;
|
||||||
|
|
||||||
|
public sealed class DependencyInjectionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||||
|
if (File.Exists(_path))
|
||||||
|
File.Delete(_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IConfiguration Config() =>
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _path })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddZbLocalDb_ResolvesILocalDb_Singleton()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddZbLocalDb(Config());
|
||||||
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var a = provider.GetRequiredService<ILocalDb>();
|
||||||
|
var b = provider.GetRequiredService<ILocalDb>();
|
||||||
|
Assert.Same(a, b);
|
||||||
|
|
||||||
|
await a.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
||||||
|
await a.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 1 });
|
||||||
|
var rows = await a.QueryAsync("SELECT COUNT(*) FROM t", r => r.GetInt64(0));
|
||||||
|
Assert.Equal(1L, rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddZbLocalDb_WithRegistrations_TablesRegisteredOnStart()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddZbLocalDb(Config(), db =>
|
||||||
|
{
|
||||||
|
using var conn = db.CreateConnection();
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE TABLE widget (id INTEGER PRIMARY KEY, name TEXT)";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
db.RegisterReplicated("widget");
|
||||||
|
});
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var db = provider.GetRequiredService<ILocalDb>();
|
||||||
|
Assert.Contains("widget", db.ReplicatedTables.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validator_EmptyPath_Fails()
|
||||||
|
{
|
||||||
|
var result = new LocalDbOptionsValidator().Validate(
|
||||||
|
null, new LocalDbOptions { Path = " " });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures, f => f.Contains("LocalDb:Path", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validator_NonPositiveBusyTimeout_Fails()
|
||||||
|
{
|
||||||
|
var result = new LocalDbOptionsValidator().Validate(
|
||||||
|
null, new LocalDbOptions { Path = "x.db", BusyTimeoutMs = 0 });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures, f => f.Contains("BusyTimeoutMs", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validator_InvalidSynchronous_Fails()
|
||||||
|
{
|
||||||
|
var result = new LocalDbOptionsValidator().Validate(
|
||||||
|
null, new LocalDbOptions { Path = "x.db", Synchronous = "SOMETIMES" });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures, f => f.Contains("Synchronous", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validator_ValidOptions_Succeeds()
|
||||||
|
{
|
||||||
|
var result = new LocalDbOptionsValidator().Validate(
|
||||||
|
null, new LocalDbOptions { Path = "x.db", BusyTimeoutMs = 5000, Synchronous = "normal" });
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bind_NonObjectDictionary_Throws()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddZbLocalDb(Config());
|
||||||
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
var db = provider.GetRequiredService<ILocalDb>();
|
||||||
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
||||||
|
|
||||||
|
var stringDict = new Dictionary<string, string> { ["id"] = "1" };
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||||
|
db.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", stringDict));
|
||||||
|
|
||||||
|
var hashtable = new Hashtable { ["id"] = 1 };
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||||
|
db.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", hashtable));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user