From 13d628a07f47cddecfacff96abad19c427d524e7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 11 Oct 2026 02:23:21 -0400 Subject: [PATCH] feat(historian): QuestDb provider, registry + consumer-gated validator, compose env Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docker-dev/docker-compose.yml | 6 + .../QuestDbHistorianProvider.cs | 106 ++++++++++++++++++ .../Configuration/HistorianBackendRegistry.cs | 13 +++ .../ServerHistorianOptionsValidator.cs | 7 ++ .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + .../QuestDbHistorianProviderTests.cs | 90 +++++++++++++++ .../ServerHistorianOptionsValidatorTests.cs | 85 ++++++++++++++ .../HistorianBackendRegistryTests.cs | 43 +++++++ 8 files changed, 351 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianProvider.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests/QuestDbHistorianProviderTests.cs diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 3d410597..d6e439b8 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -441,6 +441,9 @@ services: # docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2 ServerHistorian__Backend: "${OTOPCUA_HISTORIAN_BACKEND:-Gateway}" ServerHistorian__SqlServer__ConnectionString: "${OTOPCUA_HISTORIAN_SQL:-Server=sql,1433;Database=OtOpcUaHistorian;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;Encrypt=False}" + # QuestDb = the QuestDB historian; the `questdb` service comes from the docker-dev/docker-compose.questdb.yml overlay + # (add `-f docker-dev/docker-compose.questdb.yml` and OTOPCUA_HISTORIAN_BACKEND=QuestDb). + ServerHistorian__QuestDb__ConnectionString: "${OTOPCUA_HISTORIAN_QUESTDB:-Host=questdb;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading}" # Continuous historization of driver tag values (Gitea #491's live gate). Gated on BOTH # ContinuousHistorization:Enabled AND ServerHistorian:Enabled — with either off no recorder is # spawned, so this stays inert on the default rig. OutboxPath is the FasterLog crash boundary and @@ -574,6 +577,9 @@ services: # docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2 ServerHistorian__Backend: "${OTOPCUA_HISTORIAN_BACKEND:-Gateway}" ServerHistorian__SqlServer__ConnectionString: "${OTOPCUA_HISTORIAN_SQL:-Server=sql,1433;Database=OtOpcUaHistorian;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;Encrypt=False}" + # QuestDb = the QuestDB historian; the `questdb` service comes from the docker-dev/docker-compose.questdb.yml overlay + # (add `-f docker-dev/docker-compose.questdb.yml` and OTOPCUA_HISTORIAN_BACKEND=QuestDb). + ServerHistorian__QuestDb__ConnectionString: "${OTOPCUA_HISTORIAN_QUESTDB:-Host=questdb;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading}" # Continuous historization — see central-1 for the full note + the gate recipe. Both centrals # carry it so the pair is symmetric; :9200 round-robins them, and the recorder must exist on # whichever node holds the drivers. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianProvider.cs new file mode 100644 index 00000000..39af8413 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianProvider.cs @@ -0,0 +1,106 @@ +using System.Collections.Frozen; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb; + +/// +/// for the QuestDB historian. Supports every +/// and HistoryUpdate (soft delete). All seams share ONE connection helper, ONE +/// schema bootstrapper, ONE tag catalog (R7: the only "known tag" authority) and ONE WAL visibility wait, so the +/// schema is ensured once and known tags are cached once. +/// +/// +/// The shared state is built on the first Create* call, not in the constructor: the constructor does no +/// validation and no I/O, so resolving the provider for a backend nobody consumes never fails on its options +/// (they are validated, consumer-gated, by the host's ServerHistorianOptionsValidator). The first +/// Create* call's service provider supplies the logger factory the shared pieces log through. +/// +public sealed class QuestDbHistorianProvider : IHistorianBackendProvider +{ + private static readonly FrozenSet Kinds = + Enum.GetValues().ToFrozenSet(); + + private readonly QuestDbHistorianOptions _options; + private readonly object _initLock = new(); + private Shared? _shared; + + /// Creates the provider over the ServerHistorian:QuestDb options (validated elsewhere). + /// The ServerHistorian:QuestDb options. + public QuestDbHistorianProvider(QuestDbHistorianOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public string Name => "QuestDb"; + + /// + public IReadOnlySet SupportedKinds => Kinds; + + private static ILoggerFactory Loggers(IServiceProvider sp) => + (sp.GetService(typeof(ILoggerFactory)) as ILoggerFactory) ?? NullLoggerFactory.Instance; + + private Shared Get(IServiceProvider sp) + { + if (Volatile.Read(ref _shared) is { } built) return built; + lock (_initLock) + { + if (_shared is { } raced) return raced; + var loggers = Loggers(sp); + var conn = new QuestDbConnection(_options); + var bootstrap = new QuestDbSchemaBootstrapper(conn, _options, loggers.CreateLogger()); + var wal = new QuestDbWalVisibility(conn, _options.VisibilityTimeout, loggers.CreateLogger()); + var shared = new Shared(conn, bootstrap, new QuestDbTagCatalog(conn, bootstrap, wal), wal); + Volatile.Write(ref _shared, shared); + return shared; + } + } + + /// + public IHistorianDataSource CreateDataSource(IServiceProvider sp) + { + var s = Get(sp); + return new QuestDbHistorianDataSource(s.Conn, s.Bootstrap, s.Catalog, + Loggers(sp).CreateLogger()); + } + + /// + public IHistorianValueWriter CreateValueWriter(IServiceProvider sp) + { + var s = Get(sp); + return new QuestDbHistorianValueWriter(s.Conn, s.Catalog, s.Wal, + Loggers(sp).CreateLogger()); + } + + /// + public IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp) + { + var s = Get(sp); + return new QuestDbAlarmHistorianWriter(s.Conn, s.Bootstrap, s.Wal, + Loggers(sp).CreateLogger()); + } + + /// + public IHistorianProvisioning CreateProvisioning(IServiceProvider sp) + { + var s = Get(sp); + return new QuestDbTagProvisioner(s.Conn, s.Bootstrap, s.Catalog, s.Wal, + Loggers(sp).CreateLogger()); + } + + /// + /// The soft-delete updater: deletes write tombstones (INSERT only, never QuestDB UPDATE/DELETE). + public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp) + { + var s = Get(sp); + return new QuestDbHistorianDataUpdater(s.Conn, s.Catalog, s.Wal, + Loggers(sp).CreateLogger()); + } + + private sealed record Shared( + QuestDbConnection Conn, QuestDbSchemaBootstrapper Bootstrap, QuestDbTagCatalog Catalog, QuestDbWalVisibility Wal); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs index ce68db91..a14e5bd6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb; using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; @@ -25,11 +26,23 @@ public static class HistorianBackendRegistry return o; } + /// Backend name for the QuestDB historian. + public const string QuestDbBackendName = "QuestDb"; + + /// Binds the ServerHistorian:QuestDb section (shared by the registry and the validator). + public static QuestDbHistorianOptions BindQuestDb(IConfiguration configuration) + { + var o = new QuestDbHistorianOptions(); + configuration.GetSection($"{ServerHistorianOptions.SectionName}:QuestDb").Bind(o); + return o; + } + private static readonly IReadOnlyDictionary> Factories = new Dictionary>(StringComparer.OrdinalIgnoreCase) { [ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o), [SqlServerBackendName] = (_, c) => new SqlServerHistorianProvider(BindSqlServer(c)), + [QuestDbBackendName] = (_, c) => new QuestDbHistorianProvider(BindQuestDb(c)), }; /// The registered backend names, for validation messages. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs index e421747f..ac51be90 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs @@ -78,6 +78,13 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests/QuestDbHistorianProviderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests/QuestDbHistorianProviderTests.cs new file mode 100644 index 00000000..b5df992b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests/QuestDbHistorianProviderTests.cs @@ -0,0 +1,90 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests; + +public sealed class QuestDbHistorianProviderTests +{ + private static QuestDbHistorianProvider Make() => new(new QuestDbHistorianOptions + { + ConnectionString = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading", + }); + + [Fact] + public void Name_and_all_six_kinds() + { + var p = Make(); + p.Name.ShouldBe("QuestDb"); + p.SupportedKinds.ShouldBe(Enum.GetValues(), ignoreOrder: true); + p.SupportedKinds.Count.ShouldBe(6); + } + + [Fact] + public void Creates_all_five_seams_without_touching_questdb() + { + var sp = new NullSp(); + var p = Make(); + p.CreateDataSource(sp).ShouldBeOfType(); + p.CreateValueWriter(sp).ShouldBeOfType(); + p.CreateAlarmWriter(sp).ShouldBeOfType(); + p.CreateProvisioning(sp).ShouldBeOfType(); + p.CreateUpdater(sp).ShouldBeOfType(); + } + + [Fact] + public void Supported_kinds_are_a_frozen_set() => + Make().SupportedKinds.ShouldBeAssignableTo>(); + + /// + /// An invalid table prefix must not fail provider construction (the host resolves the provider even when no + /// consumer is enabled); it surfaces only when a seam is actually built. + /// + [Fact] + public void An_invalid_prefix_fails_only_when_a_seam_is_built() + { + var p = new QuestDbHistorianProvider(new QuestDbHistorianOptions { ConnectionString = "Host=x", TablePrefix = "bad;prefix" }); + p.Name.ShouldBe("QuestDb"); + Should.Throw(() => p.CreateDataSource(new NullSp())); + } + + [Fact] + public void Every_seam_shares_one_connection_bootstrapper_catalog_and_wal_wait() + { + var sp = new NullSp(); + var p = Make(); + var a = (QuestDbHistorianDataSource)p.CreateDataSource(sp); + var b = (QuestDbHistorianDataSource)p.CreateDataSource(sp); + var w = p.CreateValueWriter(sp); + var u = p.CreateUpdater(sp)!; + var prov = p.CreateProvisioning(sp); + var alarm = p.CreateAlarmWriter(sp); + + Field(a, "_conn").ShouldBeSameAs(Field(b, "_conn")); + Field(a, "_conn").ShouldBeSameAs(Field(w, "_conn")); + Field(a, "_conn").ShouldBeSameAs(Field(u, "_conn")); + Field(a, "_conn").ShouldBeSameAs(Field(alarm, "_conn")); + Field(a, "_bootstrap").ShouldBeSameAs(Field(b, "_bootstrap")); + Field(a, "_bootstrap").ShouldBeSameAs(Field(prov, "_bootstrap")); + Field(a, "_bootstrap").ShouldBeSameAs(Field(alarm, "_bootstrap")); + Field(a, "_catalog").ShouldBeSameAs(Field(b, "_catalog")); + Field(a, "_catalog").ShouldBeSameAs(Field(w, "_catalog")); + Field(a, "_catalog").ShouldBeSameAs(Field(u, "_tags")); + Field(a, "_catalog").ShouldBeSameAs(Field(prov, "_catalog")); + Field(w, "_wal").ShouldBeSameAs(Field(u, "_wal")); + Field(w, "_wal").ShouldBeSameAs(Field(prov, "_wal")); + Field(w, "_wal").ShouldBeSameAs(Field(alarm, "_wal")); + } + + [Fact] + public void The_constructor_rejects_null_options() => + Should.Throw(() => new QuestDbHistorianProvider(null!)); + + private static object? Field(object o, string name) => + o.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(o); + + private sealed class NullSp : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs index 00945b11..9b378e4a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -375,4 +375,89 @@ public sealed class ServerHistorianOptionsValidatorTests result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:Schema")); } + + private static ServerHistorianOptionsValidator QuestDbSut(string? connectionString, string? prefix = null, bool alarmHistorianEnabled = false) + { + var values = new Dictionary { ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false" }; + if (connectionString is not null) values["ServerHistorian:QuestDb:ConnectionString"] = connectionString; + if (prefix is not null) values["ServerHistorian:QuestDb:TablePrefix"] = prefix; + return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build()); + } + + /// QuestDb with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire. + [Fact] + public void QuestDb_backend_with_connection_string_passes_without_gateway_settings() + { + var result = QuestDbSut("Host=questdb;Port=8812").Validate(null, + new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }); + + result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? [])); + } + + /// QuestDb with no connection string fails, naming the key, and never asks for gateway settings. + [Fact] + public void QuestDb_backend_without_connection_string_fails_naming_the_key() + { + var result = QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "questdb", Enabled = true }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:ConnectionString")); + result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint")); + result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:ApiKey")); + } + + /// Every QuestDbHistorianOptions.Validate() error is reported, each naming its key. + [Fact] + public void QuestDb_backend_reports_every_options_error_naming_the_key() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["AlarmHistorian:Enabled"] = "false", + ["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix", + ["ServerHistorian:QuestDb:RetentionDays"] = "0", + ["ServerHistorian:QuestDb:CommandTimeout"] = "00:00:00", + ["ServerHistorian:QuestDb:VisibilityTimeout"] = "-00:00:01", + }).Build(); + var result = new ServerHistorianOptionsValidator(configuration) + .Validate(null, new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }); + + result.Failed.ShouldBeTrue(); + foreach (var key in new[] { "ConnectionString", "TablePrefix", "RetentionDays", "CommandTimeout", "VisibilityTimeout" }) + result.Failures.ShouldContain(f => f.Contains($"ServerHistorian:QuestDb:{key}"), key); + } + + /// Unconsumed QuestDb section (nothing enabled) is not validated. + [Fact] + public void QuestDb_backend_not_consumed_passes_without_connection_string() => + QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "QuestDb" }).Failed.ShouldBeFalse(); + + /// + /// An invalid ServerHistorian:QuestDb:TablePrefix must not fail the host when nothing consumes the + /// section: does not throw and the validator does not check it. + /// + [Fact] + public void QuestDb_invalid_prefix_not_consumed_neither_resolve_nor_validation_fails() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["AlarmHistorian:Enabled"] = "false", + ["ServerHistorian:QuestDb:ConnectionString"] = "Host=questdb;Port=8812", + ["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix", + }).Build(); + var options = new ServerHistorianOptions { Backend = "QuestDb" }; + + Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration)); + new ServerHistorianOptionsValidator(configuration).Validate(null, options).Failed.ShouldBeFalse(); + } + + /// Consumed (here by AlarmHistorian alone), the same invalid prefix is a named validator error. + [Fact] + public void QuestDb_invalid_prefix_consumed_is_a_validator_error_naming_the_key() + { + var options = new ServerHistorianOptions { Backend = "QuestDb" }; + var result = QuestDbSut("Host=questdb;Port=8812", "bad;prefix", alarmHistorianEnabled: true).Validate(null, options); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix")); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs index 9251df6a..fe66467c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs @@ -6,6 +6,7 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb; using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; @@ -86,4 +87,46 @@ public sealed class HistorianBackendRegistryTests holder.Updater.ShouldNotBeNull(); holder.Updater!.GetType().Name.ShouldBe("SqlServerHistorianDataUpdater"); } + + private static IConfiguration QuestDbCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb", + ["ServerHistorian:QuestDb:TablePrefix"] = "rig_", + }).Build(); + + [Theory] + [InlineData("questdb")] + [InlineData("QUESTDB")] + [InlineData("QuestDb")] + public void QuestDb_backend_resolves_case_insensitively(string name) => + HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = name, Enabled = true }, QuestDbCfg()) + .ShouldBeOfType().Name.ShouldBe("QuestDb"); + + [Fact] + public void QuestDb_backend_resolves_and_builds_the_questdb_seams() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "questdb", Enabled = true }, QuestDbCfg()); + + p.ShouldBeOfType(); + HistorianBackendRegistry.KnownBackends.ShouldContain("QuestDb"); + p.SupportedKinds.Count.ShouldBe(6); + p.SupportedKinds.ShouldContain(HistorianValueKind.String); + p.SupportedKinds.ShouldContain(HistorianValueKind.Empty); + p.CreateDataSource(sp).ShouldBeOfType(); + p.CreateValueWriter(sp).GetType().Name.ShouldBe("QuestDbHistorianValueWriter"); + p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("QuestDbAlarmHistorianWriter"); + p.CreateProvisioning(sp).GetType().Name.ShouldBe("QuestDbTagProvisioner"); + var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp)); + holder.Updater.ShouldNotBeNull(); + holder.Updater!.GetType().Name.ShouldBe("QuestDbHistorianDataUpdater"); + } + + [Fact] + public void BindQuestDb_reads_the_QuestDb_section() + { + var o = HistorianBackendRegistry.BindQuestDb(QuestDbCfg()); + o.ConnectionString.ShouldContain("Port=18812"); + o.TablePrefix.ShouldBe("rig_"); + } }