diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs new file mode 100644 index 0000000..4595c93 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/DependencyInjection/LocalDbServiceCollectionExtensions.cs @@ -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; + +/// DI registration for the embedded SQLite local database. +public static class LocalDbServiceCollectionExtensions +{ + /// + /// Binds and validates from the "LocalDb" configuration section and + /// registers as a singleton. + /// + /// + /// 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 ) and call + /// to opt them into replication. + /// + public static IServiceCollection AddZbLocalDb( + this IServiceCollection services, + IConfiguration configuration, + Action? onReady = null) + { + services.AddOptions() + .Bind(configuration.GetSection("LocalDb")) + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, LocalDbOptionsValidator>()); + + services.TryAddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + var db = new SqliteLocalDb(options); + onReady?.Invoke(db); + return db; + }); + + return services; + } +} diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs index 9c42ea8..0f4594f 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Internal/SqliteLocalDb.cs @@ -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 (entries bind as @Key). A Dictionary, 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); diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs new file mode 100644 index 0000000..fd6b238 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/LocalDbOptionsValidator.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.LocalDb; + +/// +/// Validates at startup, accumulating every failure into one result so +/// ValidateOnStart reports all problems at once. Mirrors the fail-closed rules the +/// SqliteLocalDb constructor also enforces. +/// +public sealed class LocalDbOptionsValidator : IValidateOptions +{ + // 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(); + + 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; + } +} diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..caf65d8 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/DependencyInjectionTests.cs @@ -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 { ["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(); + var b = provider.GetRequiredService(); + 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(); + 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(); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); + + var stringDict = new Dictionary { ["id"] = "1" }; + await Assert.ThrowsAsync(() => + db.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", stringDict)); + + var hashtable = new Hashtable { ["id"] = 1 }; + await Assert.ThrowsAsync(() => + db.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", hashtable)); + } +}