Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs
T
Joseph Doherty 2331f7ed14
v2-ci / unit-tests (push) Waiting to run
feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
allowWrites is no longer inert: SqlDriver implements IWritable, default off.

- Gates (outermost first): node Writable + WriteOperate (server), driver
  allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean;
  anything else refuses the tag, and the deploy gate says why), and a model that
  names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and
  Query are BadNotWritable.
- SqlWritePlanner: the only statement a write can become is
  UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the
  catalog-gate-canonical identifiers (dialect-quoted) with value and key bound;
  value-before-key marker order so positional ODBC binds correctly. No authored
  write SQL, INSERT, UPSERT or procedure.
- Executed in a transaction that commits only when exactly one row was touched:
  0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError,
  unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout;
  provider errors -> BadCommunicationError; health untouched.
- Discovery advertises Operate only where a write could succeed.
- AdminUI: driver form's Allow writes switch is live; tag editor gains a
  per-tag write switch; model validates writable strictly (parity corpus).
- Factory no longer warns "allowWrites ignored"; it logs whether writes are on.
- Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate,
  readback, absent key, two-row rollback, gate-rejected tag, discovery);
  MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback,
  server-side grant refusal, default-off). Seed adds a setpoints table and the
  one UPDATE grant.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00

557 lines
24 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Proves the composition layer: <see cref="SqlDriverFactoryExtensions"/> (config blob → live driver) and
/// <see cref="SqlConnectionStringResolver"/> (a <c>connectionStringRef</c> name → the secret it stands for).
/// <para>Three things here are behaviour rather than plumbing, and each has its own test below.
/// <b>(1) The string-enum guard</b> — the driver-page enum-serialization defect that bit the AdminUI shipped
/// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the
/// driver at deploy time. The factory's options therefore both accept a numeric <c>provider</c> and write the
/// NAME. <b>(2) Config validation</b> — <c>operationTimeout &gt; commandTimeout</c> is deliberately
/// unenforced in the reader and the shell, so the factory is the only place it can be caught before a
/// deployment silently masks its own server-side backstop. <b>(3) Credential hygiene</b> — the resolved
/// connection string is a secret; it must not reach a log line or an exception message, and the tests below
/// assert that rather than trusting the reviewer.</para>
/// </summary>
public sealed class SqlDriverFactoryTests
{
// ---- the plan's headline legs ----
/// <summary>
/// <b>R06:S-8.</b> A malformed config used to quote System.Text.Json's message — which echoes the
/// offending token — into the exception that becomes <c>InitializeFailed</c> → health
/// <c>LastError</c> → <c>/hosts</c>. A pasted connection string is exactly the malformed text that
/// reaches here, so the parser text (and the inner exception carrying it) is withheld, as the probe and
/// browser already do. The message still names the instance and says what is wrong.
/// </summary>
[Fact]
public void CreateInstance_malformedJson_withholdsTheParserText()
{
const string secret = "hunter2-do-not-log";
var ex = Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"SqlServer","connectionStringRef": Password={{secret}} }""", null));
ex.Message.ShouldContain("d1");
ex.Message.ShouldContain("not valid JSON");
ex.Message.ShouldNotContain(secret);
ex.Message.ShouldNotContain("Password");
ex.InnerException.ShouldBeNull();
}
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
.Message.ShouldContain("connectionStringRef");
[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;");
SqlConnectionStringResolver.Resolve(name).ShouldBe("Server=x;Database=y;");
}
[Fact]
public void Provider_serializesAsNameNotNumber()
{
var json = JsonSerializer.Serialize(
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
SqlDriverFactoryExtensions.JsonOptionsForTest);
json.ShouldContain("\"SqlServer\"");
json.ShouldNotContain("\"provider\":0");
}
// ---- the other half of the enum guard: a NUMERIC provider must still parse ----
[Fact]
public void CreateInstance_providerWrittenAsANumber_stillParses()
{
// The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that
// would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the
// options accept both spellings on the way IN, and only ever emit the name on the way OUT.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null);
driver.DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
[Fact]
public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal()
{
// A config blob authored against a newer (or older) schema must not brick the driver.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null);
driver.DriverInstanceId.ShouldBe("d1");
}
// ---- connection-string resolution ----
[Fact]
public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable()
{
var name = NewRef();
var ex = Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
// The operator's next action is "set this variable", so the message must name it exactly.
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
}
[Fact]
public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent()
{
// An empty value is a half-provisioned deployment, not a connection string — failing here beats
// handing "" to the provider and reporting an unintelligible ADO.NET error.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), " ");
Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
}
[Fact]
public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef()
{
var name = NewRef();
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
}
// ---- provider gate ----
/// <summary>
/// The falsifiability control for the widened gate: every declared member now constructs, so an
/// out-of-range ordinal (a config authored against a future enum) stands in for "a provider this build
/// does not carry" — it must fail loudly at construction, naming itself. Without this leg, a gate that
/// had quietly become "construct anything" would pass every test below.
/// </summary>
[Fact]
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":99,"connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain("99");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// The reserved names are named as reserved — an operator reading the rejection must be able to tell
/// "misspelled" from "this build does not carry it", so the message enumerates both halves.
/// </summary>
[Fact]
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":99,"connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain(nameof(SqlProvider.SqlServer));
ex.Message.ShouldContain(nameof(SqlProvider.Odbc));
ex.Message.ShouldContain(nameof(SqlProvider.Postgres));
ex.Message.ShouldContain(nameof(SqlProvider.Oracle));
ex.Message.ShouldContain(nameof(SqlProvider.MySql));
}
/// <summary>
/// The widened gate, asserted on the thing that actually varies: which dialect the config binds to.
/// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate
/// that fell through to <c>SqlServerDialect</c> would also construct without complaint and then speak
/// T-SQL at a Postgres server.
/// </summary>
[Theory]
[InlineData("SqlServer", typeof(SqlServerDialect))]
[InlineData("Postgres", typeof(PostgresDialect))]
[InlineData("Oracle", typeof(OracleDialect))]
[InlineData("MySql", typeof(MySqlDialect))]
[InlineData("Odbc", typeof(OdbcDialect))]
public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var binding = SqlDriverFactoryExtensions.ParseBinding(
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""");
binding.Dialect.ShouldBeOfType(expected);
binding.Dialect.Provider.ShouldBe(Enum.Parse<SqlProvider>(provider));
}
/// <summary>
/// End-to-end through the registered factory entry point, so the widening is proven on the path the
/// bootstrapper actually calls — not only on the internal parse.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
[InlineData("MySql")]
[InlineData("Odbc")]
public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
await using var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null);
driver.ShouldNotBeNull();
}
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
[Fact]
public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected()
{
// Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout
// backstop can never be reached — the deployment silently loses one of its two independent bounds.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1",
$$"""
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
""",
null));
ex.Message.ShouldContain("operationTimeout");
ex.Message.ShouldContain("commandTimeout");
}
[Fact]
public void CreateInstance_equalTimeouts_areAlsoRejected()
{
// "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect
// with a coin toss on top.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1",
$$"""
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"}
""",
null));
}
[Theory]
[InlineData("\"commandTimeout\":\"00:00:00\"")]
[InlineData("\"operationTimeout\":\"-00:00:05\"")]
[InlineData("\"defaultPollInterval\":\"00:00:00\"")]
[InlineData("\"maxConcurrentGroups\":0")]
public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null));
}
[Fact]
public void CreateInstance_theDocumentedDefaults_passValidation()
{
// The falsifiability control for the four rejection legs above: an all-defaults config must build.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var options = SqlDriverFactoryExtensions.BuildOptions(
new SqlDriverConfigDto { ConnectionStringRef = name }, "d1");
options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10));
options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15));
options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
options.MaxConcurrentGroups.ShouldBe(4);
options.NullIsBad.ShouldBeFalse();
options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout);
}
// ---- rawTags: how the deploy artifact actually delivers a driver's tags ----
[Fact]
public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions()
{
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
"""
{
"connectionStringRef":"anything",
"rawTags":[
{"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false},
{"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}
]
}
""",
SqlDriverFactoryExtensions.JsonOptionsForTest)!;
var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1");
options.RawTags.Count.ShouldBe(2);
options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed");
options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp");
}
[Fact]
public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull()
{
var options = SqlDriverFactoryExtensions.BuildOptions(
new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1");
options.RawTags.ShouldBeEmpty();
}
// ---- allowWrites (lmxopcua#753): honoured now, default off ----
[Theory]
[InlineData(",\"allowWrites\":true", true)]
[InlineData(",\"allowWrites\":false", false)]
[InlineData("", false)] // absent ⇒ read-only
public void ParseBinding_carriesAllowWritesOntoTheOptions_defaultOff(string fragment, bool expected)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
SqlDriverFactoryExtensions.ParseBinding("d1", $$"""{"connectionStringRef":"{{name}}"{{fragment}}}""")
.Options.AllowWrites.ShouldBe(expected);
}
[Fact]
public void CreateInstance_withAllowWrites_logsNoWarning_andSaysWritesAreOn()
{
// allowWrites used to be inert and drew a "this build ignores it" warning; it is honoured now, so the
// flag is an ordinary setting — reported in the configured line, never as a warning.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var loggerFactory = new RecordingLoggerFactory();
SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory)
.ShouldBeAssignableTo<IWritable>();
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
loggerFactory.Entries.ShouldContain(e => e.Message.Contains("allowed for opted-in tags"));
}
[Fact]
public void CreateInstance_withoutAllowWrites_logsNoWarning()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var loggerFactory = new RecordingLoggerFactory();
SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory);
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
// ...and the factory DID log — so "no warning" is a statement about severity, not about silence.
loggerFactory.Entries.ShouldNotBeEmpty();
}
// ---- credential hygiene ----
[Fact]
public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint()
{
const string secret = "SuperSecret-PleaseDoNotLogMe";
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name),
$"Server=srv;Database=db;User ID=sa;Password={secret}");
var loggerFactory = new RecordingLoggerFactory();
var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
// The credential-free rendering IS emitted — the factory logs where the driver points, which is what
// makes "no connection string" a real constraint rather than "log nothing and call it safe".
driver.Endpoint.ShouldBe("srv/db");
foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret);
loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into.
}
[Fact]
public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString()
{
const string secret = "SuperSecret-PleaseDoNotThrowMe";
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name),
$"Server=srv;Database=db;Password={secret}");
// Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the
// secret in hand — the case where a careless $"...{connectionString}" would actually leak.
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1",
$$"""
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
""",
null));
ex.ToString().ShouldNotContain(secret);
}
// ---- registry wiring ----
[Fact]
public void Register_registersTheFactoryUnderTheDriversTypeName()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var registry = new DriverFactoryRegistry();
SqlDriverFactoryExtensions.Register(registry);
var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull();
var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}""");
driver.ShouldBeOfType<SqlDriver>();
driver.DriverType.ShouldBe("Sql");
}
[Fact]
public void DriverTypeName_matchesTheDriversOwnConstant()
// Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart.
=> SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName);
// ---- argument guards ----
[Theory]
[InlineData("")]
[InlineData(" ")]
public void CreateInstance_blankInputs_areRejected(string blank)
{
Should.Throw<ArgumentException>(() =>
SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null));
Should.Throw<ArgumentException>(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null));
}
[Fact]
public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance()
{
var ex = Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null));
ex.Message.ShouldContain("d1");
}
/// <summary>A per-test connection-string ref, so no two tests can collide over one process-wide env var.</summary>
private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}";
}
/// <summary>
/// Sets one environment variable for the life of the scope and restores its previous value — including
/// restoring it to <see langword="null"/>. Environment variables are process-global, so a test that sets
/// one without restoring it leaks into every test that runs after it in the same process.
/// </summary>
internal sealed class EnvironmentVariableScope : IDisposable
{
private readonly string _name;
private readonly string? _previous;
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/>, remembering what was there.</summary>
/// <param name="name">The environment variable to set.</param>
/// <param name="value">The value to set it to.</param>
public EnvironmentVariableScope(string name, string? value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
/// <summary>Restores the previous value.</summary>
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
/// <summary>
/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted
/// rather than assumed.
/// </summary>
internal sealed class RecordingLoggerFactory : ILoggerFactory
{
private readonly List<LogEntry> _entries = [];
/// <summary>The log entries recorded so far, in order.</summary>
public IReadOnlyList<LogEntry> Entries
{
get { lock (_entries) return [.. _entries]; }
}
/// <inheritdoc/>
public ILogger CreateLogger(string categoryName) => new Recorder(this);
/// <inheritdoc/>
public void AddProvider(ILoggerProvider provider) { }
/// <inheritdoc/>
public void Dispose() { }
private void Record(LogLevel level, string message)
{
lock (_entries) _entries.Add(new LogEntry(level, message));
}
/// <summary>One captured log entry.</summary>
/// <param name="Level">The level it was logged at.</param>
/// <param name="Message">The formatted message.</param>
internal sealed record LogEntry(LogLevel Level, string Message);
private sealed class Recorder(RecordingLoggerFactory owner) : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> owner.Record(logLevel, formatter(state, exception));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}