diff --git a/Directory.Packages.props b/Directory.Packages.props
index 61b0df35a..16645d605 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -114,6 +114,10 @@
Microsoft.Data.SqlClient; consumers inherit it transitively. Latest stable, shipping a
native lib/net10.0 TFM. -->
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
index e92cc22de..e86d387a8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
@@ -206,29 +206,15 @@ public static class SqlDriverFactoryExtensions
}
///
- /// Selects the provider seam.
- /// This build constructs ,
- /// and . and
- /// remain reserved names on the shipped enum; authoring one is a config
- /// mistake that must fail at construction with a message that says so, rather than silently degrading
- /// to another backend's SQL dialect against a database that does not speak it.
- /// Every arm returns that provider's OWN dialect — there is no fallback arm. A default
- /// that resolved to would construct happily and then emit T-SQL
- /// (INFORMATION_SCHEMA, TOP 1, @ markers) at a Postgres or Oracle server, which
- /// fails at poll time as an unintelligible provider error rather than here as a config error.
+ /// Selects the provider seam through — the one map the probe and the
+ /// browser use too. A provider this build does not construct is a config mistake that must fail here
+ /// with a message saying so, rather than silently degrading to another backend's SQL dialect.
///
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
- => provider switch
- {
- SqlProvider.SqlServer => new SqlServerDialect(),
- SqlProvider.Postgres => new PostgresDialect(),
- SqlProvider.Oracle => new OracleDialect(),
- _ => throw new InvalidOperationException(
- $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
- $"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " +
- $"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
- $"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
- };
+ => SqlDialects.TryCreate(provider)
+ ?? throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
+ $"available in this build. {SqlDialects.DescribeAvailability()}");
///
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
index b0a51962a..e2c51d546 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
@@ -188,25 +188,13 @@ public sealed class SqlDriverProbe : IDriverProbe
internal static ISqlDialect DefaultDialectForTest(SqlProvider provider) => DefaultDialectFor(provider);
///
- /// Constructs the production dialect for a provider — the same set
- /// constructs, because Test Connect must exercise the
- /// backend a Deploy would actually use. This build constructs ,
- /// and ;
- /// and stay reserved.
- /// Keep this arm-for-arm with the factory's CreateDialect. When the factory and the
- /// browser were widened to Postgres + Oracle this switch was left SqlServer-only, so a config that
- /// deployed and browsed fine failed Test Connect with an — and
- /// no test noticed, because every probe test injects its dialect through .
+ /// Constructs the production dialect for a provider through — the
+ /// same map uses, because Test Connect must exercise the
+ /// backend a Deploy would actually use. (This switch once stayed SqlServer-only after the factory and
+ /// browser were widened, so a config that deployed fine failed Test Connect; sharing the map ends that.)
///
- private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
- {
- SqlProvider.SqlServer => new SqlServerDialect(),
- SqlProvider.Postgres => new PostgresDialect(),
- SqlProvider.Oracle => new OracleDialect(),
- _ => throw new InvalidOperationException(
- $"provider '{provider}' is not available in this build. Supported: " +
- $"'{nameof(SqlProvider.SqlServer)}', '{nameof(SqlProvider.Postgres)}', " +
- $"'{nameof(SqlProvider.Oracle)}'. Reserved (declared on the enum but not implemented): " +
- $"'{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
- };
+ private static ISqlDialect DefaultDialectFor(SqlProvider provider)
+ => SqlDialects.TryCreate(provider)
+ ?? throw new InvalidOperationException(
+ $"provider '{provider}' is not available in this build. {SqlDialects.DescribeAvailability()}");
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor
index 985f37ab9..8057ad6b3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor
@@ -18,13 +18,14 @@
- @* SqlServer, Postgres and Oracle construct a dialect; MySql/Odbc stay reserved
- SqlProvider members and are deliberately not offered (authoring one fails the driver). *@
+ @* Every provider SqlDialects constructs; Odbc stays a reserved SqlProvider member and is
+ deliberately not offered (authoring one fails the driver). *@
+
-
SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.
+
SQL Server, PostgreSQL, Oracle and MySQL/MariaDB supported; Odbc reserved.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs
index 93d1f29ff..487856631 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs
@@ -52,13 +52,14 @@ public sealed class SqlDriverBrowserTests
/// member fail here rather than silently browse as SQL Server.
///
[Fact]
- public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
+ public void DefaultDialectSelector_carriesEveryConstructedProvider()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType();
+ SqlDriverBrowser.DefaultDialectSelector(SqlProvider.MySql).ShouldBeOfType();
- SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
+ SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle, SqlProvider.MySql];
foreach (var provider in Enum.GetValues().Where(p => !carried.Contains(p)))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
@@ -188,7 +189,7 @@ public sealed class SqlDriverBrowserTests
// ---- provider availability ----
///
- /// The falsifiability control for the widened browse gate: is still
+ /// The falsifiability control for the widened browse gate: is still
/// reserved and must be reported as unavailable, naming itself.
///
[Fact]
@@ -198,9 +199,9 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync(() =>
browser.OpenAsync(
- ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None));
+ ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
- ex.Message.ShouldContain("MySql");
+ ex.Message.ShouldContain("Odbc");
ex.Message.ShouldContain("not available in this build");
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/docker-compose.yml
new file mode 100644
index 000000000..e18c0d5a8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/docker-compose.yml
@@ -0,0 +1,65 @@
+# Dedicated, DISPOSABLE MySQL 8 for the Sql driver's MySql live gate (MySqlReadTests, lmxopcua#753).
+#
+# ── SAFETY ─────────────────────────────────────────────────────────────────────
+# This stack owns its OWN container, `otopcua-sql-mysql`, on host port 13306, with
+# `restart: "no"` so a killed container stays down. Nothing else on the rig runs MySQL.
+# Tear it down when done: docker compose down -v
+#
+# ── RUN IT (any docker host — a laptop is fine) ────────────────────────────────
+# cd tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql
+# SQL_MYSQL_PASSWORD=... docker compose up -d # the one-shot seed applies seed-mysql.sql
+#
+# export SQL_MYSQL_ENDPOINT=127.0.0.1:13306
+# export SQL_MYSQL_PASSWORD=... # the value the stack was brought up with
+# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
+# --filter "FullyQualifiedName~MySqlReadTests"
+#
+# docker compose down -v
+#
+# `--log-bin-trust-function-creators=1` lets the seed create `bump_counter()`, a stored function
+# that WRITES; the named-query suite calls it through a SELECT to prove the driver's rolled-back
+# transaction discards a side effect the read-only guard cannot see.
+
+name: otopcua-sql-mysql
+
+services:
+ mysql:
+ image: mysql:8.4
+ container_name: otopcua-sql-mysql
+ labels:
+ project: lmxopcua
+ restart: "no"
+ command: ["--log-bin-trust-function-creators=1"]
+ environment:
+ # Dev-only sandbox password for a throwaway container; the seed gives the unprivileged
+ # `otopcua` login the SAME value, which is what the suite reads from SQL_MYSQL_PASSWORD.
+ MYSQL_ROOT_PASSWORD: "${SQL_MYSQL_PASSWORD:-Sqlmysql_dev_pw1}"
+ ports:
+ - "13306:3306"
+ healthcheck:
+ test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
+ interval: 5s
+ timeout: 5s
+ retries: 24
+ start_period: 20s
+
+ # One-shot seed: waits for the healthcheck, applies seed-mysql.sql as root, exits.
+ mysql-seed:
+ image: mysql:8.4
+ labels:
+ project: lmxopcua
+ restart: "no"
+ depends_on:
+ mysql:
+ condition: service_healthy
+ environment:
+ MYSQL_PWD: "${SQL_MYSQL_PASSWORD:-Sqlmysql_dev_pw1}"
+ APP_PASSWORD: "${SQL_MYSQL_PASSWORD:-Sqlmysql_dev_pw1}"
+ volumes:
+ - ./seed-mysql.sql:/seed.sql:ro
+ entrypoint:
+ - "/bin/sh"
+ - "-c"
+ # The app password is substituted into the seed on the way in (MySQL has no psql-style
+ # variables); --force is NOT used, so a broken seed fails the service.
+ - "sed \"s/__APP_PASSWORD__/$${APP_PASSWORD}/g\" /seed.sql | mysql -h mysql -uroot"
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/seed-mysql.sql b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/seed-mysql.sql
new file mode 100644
index 000000000..c605ae978
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/seed-mysql.sql
@@ -0,0 +1,73 @@
+-- Seed for the Sql driver's MySQL live gate (MySqlReadTests, lmxopcua#753), applied once by the compose
+-- `mysql-seed` one-shot as root against the OWNED `otopcua-sql-mysql` container. `__APP_PASSWORD__` is
+-- substituted by the one-shot before this reaches the server.
+--
+-- Table shapes mirror the SQL Server and PostgreSQL fixtures row for row, so the same assertions hold on all
+-- backends. Two things are load-bearing:
+--
+-- 1. The suite connects as `otopcua`, which holds SELECT on `sqlpoll` and nothing else (plus EXECUTE on the
+-- side-effect function, below). A MySQL "schema" is a database, and information_schema shows a principal
+-- only the objects it holds a privilege on — so MySqlDialect.ListSchemasSql returning `sqlpoll` for this
+-- login is the real provisioning check, not a superuser's view.
+-- 2. Every identifier is lower case. The suite authors the table name in UPPER case and expects the catalog
+-- gate to resolve it through a unique case-insensitive match: on Linux, MySQL table names are case-
+-- SENSITIVE, so without the gate's substitution the quoted upper-case name would name no table.
+
+DROP DATABASE IF EXISTS sqlpoll;
+CREATE DATABASE sqlpoll;
+USE sqlpoll;
+
+-- Key-value (EAV) source.
+CREATE TABLE tag_values (
+ tag_name varchar(128) NOT NULL,
+ num_value double NULL,
+ sample_ts datetime(3) NOT NULL
+);
+INSERT INTO tag_values (tag_name, num_value, sample_ts) VALUES
+ ('Line1.Speed', 42.0, '2026-07-24 10:00:00'),
+ ('Line1.Temp', NULL, '2026-07-24 10:00:01'),
+ ('Line1.Pressure', 3.5, '2026-07-24 10:00:02');
+
+-- Wide-row source. Station 8 is inserted LAST and is the newest sample; station 7 is first — so a plan that
+-- lost its ORDER BY … DESC or its LIMIT 1 returns a different, wrong value.
+CREATE TABLE latest_status (
+ station_id int NOT NULL,
+ oven_temp double NULL,
+ pressure double NULL,
+ sample_ts datetime(3) NOT NULL
+);
+INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts) VALUES
+ (7, 180.5, 1.2, '2026-07-24 10:00:00'),
+ (9, NULL, 1.6, '2026-07-24 10:00:01'),
+ (8, 210.25, 1.4, '2026-07-24 10:00:02');
+
+-- Integer-keyed EAV source: an authored text keyValue must compare against an INT key column.
+CREATE TABLE int_tag_values (
+ tag_id int NOT NULL,
+ num_value double NULL,
+ sample_ts datetime(3) NOT NULL
+);
+INSERT INTO int_tag_values (tag_id, num_value, sample_ts) VALUES
+ (101, 77.5, '2026-07-24 10:00:00'),
+ (102, 12.0, '2026-07-24 10:00:01');
+
+-- Named-query rollback probe: bump_counter() WRITES a row and returns 1. Called through a SELECT, it passes the
+-- read-only guard (nothing lexical says "write"); the driver's always-rolled-back transaction is what must
+-- discard the row. InnoDB, so the insert is transactional. SQL SECURITY DEFINER (root), so `otopcua` needs only
+-- EXECUTE — it holds no INSERT of its own.
+CREATE TABLE side_effects (n int NOT NULL) ENGINE = InnoDB;
+DELIMITER //
+CREATE FUNCTION bump_counter() RETURNS int
+ NOT DETERMINISTIC MODIFIES SQL DATA SQL SECURITY DEFINER
+BEGIN
+ INSERT INTO side_effects (n) VALUES (1);
+ RETURN 1;
+END//
+DELIMITER ;
+
+-- ── the read-only login ──────────────────────────────────────────────────────────────────────
+DROP USER IF EXISTS 'otopcua'@'%';
+CREATE USER 'otopcua'@'%' IDENTIFIED BY '__APP_PASSWORD__';
+GRANT SELECT ON sqlpoll.* TO 'otopcua'@'%';
+GRANT EXECUTE ON FUNCTION sqlpoll.bump_counter TO 'otopcua'@'%';
+FLUSH PRIVILEGES;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/MySqlReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/MySqlReadTests.cs
new file mode 100644
index 000000000..ee922f10d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/MySqlReadTests.cs
@@ -0,0 +1,484 @@
+using System.Data.Common;
+using System.Globalization;
+using System.Text.Json.Nodes;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
+
+///
+/// The Sql driver against a real MySQL 8 (lmxopcua#753), over the MySqlConnector provider
+/// MySqlDialect ships — the MySQL counterpart of , narrowed the same way
+/// to what a dialect can get wrong: liveness, the information_schema catalog SQL (a MySQL database
+/// playing the schema), backtick quoting, the trailing LIMIT, an INT key compared against authored
+/// text, case folding against case-SENSITIVE table names, the browse walk, the factory/probe provider gate —
+/// and the named-query model, including the rolled-back transaction proven against a real side effect.
+/// Env-gated; skipping is the normal outcome. See .
+///
+[Collection(MySqlCollection.Name)]
+public sealed class MySqlReadTests
+{
+ private readonly MySqlFixture _my;
+
+ /// Initializes a new instance of the class.
+ /// The seeded live-server fixture (collection-scoped).
+ public MySqlReadTests(MySqlFixture my) => _my = my;
+
+ /// Liveness + catalog load succeed for a SELECT-only login, and the endpoint is described credential-free.
+ [Fact]
+ public async Task InitializeAsync_realMySql_reportsHealthyAndDescribesTheEndpoint()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", MySqlFixture.PresentKey));
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.Endpoint.ShouldContain(MySqlFixture.Schema);
+ driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
+ driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
+ }
+
+ /// A seeded key-value row round-trips Good, with its datetime(3) source timestamp stamped UTC.
+ [Fact]
+ public async Task ReadAsync_realMySql_roundTripsSeededKeyValue()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ KeyValue("Sql/Line1/Speed", MySqlFixture.PresentKey),
+ KeyValue("Sql/Line1/Temp", MySqlFixture.NullValueKey));
+
+ var snapshots = await driver.ReadAsync(["Sql/Line1/Speed", "Sql/Line1/Temp"], TestContext.Current.CancellationToken);
+
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture).ShouldBe(MySqlFixture.PresentValue);
+ snapshots[0].SourceTimestampUtc.ShouldBe(MySqlFixture.PresentKeyTimestampUtc);
+ snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Uncertain); // a present row with a NULL cell
+ }
+
+ ///
+ /// topByTimestamp resolves to the newest row through the dialect's trailing LIMIT 1. The seed
+ /// makes the newest row neither first-inserted nor lowest-keyed, so losing either the limit or the
+ /// ORDER BY … DESC yields a wrong value.
+ ///
+ [Fact]
+ public async Task ReadAsync_realMySql_topByTimestampSelectsTheNewestRow()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(Tag("Sql/Newest/OvenTemp", new JsonObject
+ {
+ ["model"] = "WideRow",
+ ["table"] = MySqlFixture.Qualified("latest_status"),
+ ["columnName"] = "oven_temp",
+ ["rowSelector"] = new JsonObject { ["topByTimestamp"] = "sample_ts" },
+ }));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(210.25);
+ }
+
+ /// Authored key text compares against an INT key column, and against an INT where column.
+ [Fact]
+ public async Task ReadAsync_realMySql_integerKeyAndWhereColumnsCompareAgainstAuthoredText()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ Tag("Sql/IntKey/Tag101", new JsonObject
+ {
+ ["model"] = "KeyValue",
+ ["table"] = MySqlFixture.Qualified("int_tag_values"),
+ ["keyColumn"] = "tag_id",
+ ["keyValue"] = "101",
+ ["valueColumn"] = "num_value",
+ }),
+ Tag("Sql/Station7/OvenTemp", new JsonObject
+ {
+ ["model"] = "WideRow",
+ ["table"] = MySqlFixture.Qualified("latest_status"),
+ ["columnName"] = "oven_temp",
+ ["rowSelector"] = new JsonObject { ["whereColumn"] = "station_id", ["whereValue"] = 7 },
+ }));
+
+ var snapshots = await driver.ReadAsync(
+ ["Sql/IntKey/Tag101", "Sql/Station7/OvenTemp"], TestContext.Current.CancellationToken);
+
+ snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture).ShouldBe(77.5);
+ Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture).ShouldBe(180.5);
+ }
+
+ ///
+ /// An upper-cased authored table/column resolves to the catalog's lower-case spelling. MySQL table names are
+ /// case-SENSITIVE on Linux, so the backtick-quoted `TAG_VALUES` would name no table: the gate's
+ /// substitution is what makes mixed-case authoring work.
+ ///
+ [Fact]
+ public async Task CatalogGate_realMySql_foldsAnUpperCasedAuthoredNameOntoTheCatalogSpelling()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(Tag("Sql/Line1/Speed", new JsonObject
+ {
+ ["model"] = "KeyValue",
+ ["table"] = MySqlFixture.Qualified("tag_values").ToUpperInvariant(),
+ ["keyColumn"] = "TAG_NAME",
+ ["keyValue"] = MySqlFixture.PresentKey,
+ ["valueColumn"] = "NUM_VALUE",
+ }));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(MySqlFixture.PresentValue);
+ }
+
+ /// A table the login cannot see is rejected by the catalog gate (BadNodeIdUnknown), not queried.
+ [Fact]
+ public async Task CatalogGate_realMySql_rejectsATableTheCatalogDoesNotHold()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(Tag("Sql/Nope", new JsonObject
+ {
+ ["model"] = "KeyValue",
+ ["table"] = MySqlFixture.Qualified("no_such_table"),
+ ["keyColumn"] = "k",
+ ["keyValue"] = "x",
+ ["valueColumn"] = "v",
+ }));
+
+ (await driver.ReadAsync(["Sql/Nope"], TestContext.Current.CancellationToken))[0].StatusCode
+ .ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// The AdminUI picker's walk — schemas → tables → columns → attributes — over a real MySQL.
+ [Fact]
+ public async Task Browse_realMySql_walksSchemaToTableToColumnAndTypesTheLeaf()
+ {
+ SkipUnlessLive();
+
+ var config = new JsonObject
+ {
+ ["provider"] = nameof(SqlProvider.MySql),
+ ["connectionString"] = _my.ConnectionString,
+ }.ToJsonString();
+
+ await using var session = await new SqlDriverBrowser().OpenAsync(config, TestContext.Current.CancellationToken);
+
+ var schema = (await session.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem(
+ "the fixture login holds SELECT on exactly one database");
+ schema.DisplayName.ShouldBe(MySqlFixture.Schema);
+
+ var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
+ tables.Select(t => t.DisplayName).ShouldContain("tag_values");
+
+ var columns = await session.ExpandAsync(
+ tables.First(t => t.DisplayName == "tag_values").NodeId, TestContext.Current.CancellationToken);
+ columns.Select(c => c.DisplayName).ShouldBe(["tag_name", "num_value", "sample_ts"]);
+ columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
+
+ var attribute = (await session.AttributesAsync(
+ columns.Single(c => c.DisplayName == "num_value").NodeId, TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+ attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double
+ }
+
+ ///
+ /// The production factory and Test Connect probe both admit MySql — the provider gate the three
+ /// switches used to disagree on — and a factory-built driver reads.
+ ///
+ [Fact]
+ public async Task FactoryAndProbe_realMySql_constructAndConnect()
+ {
+ SkipUnlessLive();
+
+ var config = new JsonObject
+ {
+ ["provider"] = nameof(SqlProvider.MySql),
+ ["connectionStringRef"] = _my.ConnectionStringRef,
+ }.ToJsonString();
+
+ var probe = await new SqlDriverProbe().ProbeAsync(config, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ probe.Ok.ShouldBeTrue(probe.Message);
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", MySqlFixture.PresentKey));
+ (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))[0].StatusCode
+ .ShouldBe(SqlStatusCodes.Good);
+ }
+
+ // ---- named queries (design §5.4) against a real server ----
+
+ /// A named query with a bound parameter and a rowKey reads its row; the query bypasses the catalog.
+ [Fact]
+ public async Task NamedQuery_realMySql_readsTheKeyedRowWithABoundParameter()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ """{"stations":{"sql":"SELECT station_id AS Station, oven_temp AS Temp FROM sqlpoll.latest_status WHERE pressure > @minPressure","params":{"minPressure":1.3}}}""",
+ Tag("Sql/Q/Newest", new JsonObject
+ {
+ ["model"] = "Query", ["query"] = "stations", ["column"] = "temp",
+ ["rowKey"] = new JsonObject { ["column"] = "station", ["value"] = 8 },
+ }),
+ Tag("Sql/Q/Filtered", new JsonObject
+ {
+ ["model"] = "Query", ["query"] = "stations", ["column"] = "Temp",
+ ["rowKey"] = new JsonObject { ["column"] = "Station", ["value"] = "7" },
+ }));
+
+ var snapshots = await driver.ReadAsync(["Sql/Q/Newest", "Sql/Q/Filtered"], TestContext.Current.CancellationToken);
+
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture).ShouldBe(210.25);
+ snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // pressure 1.2 is filtered out by the bind
+ }
+
+ ///
+ /// The backstop proven against a real side effect: bump_counter() INSERTs a row and returns 1. A
+ /// SELECT bump_counter() passes the read-only guard (nothing lexical says "write"), the read is Good
+ /// (positive control: the function really ran), and the row is gone afterwards — the driver's transaction
+ /// was rolled back, not committed.
+ ///
+ [Fact]
+ public async Task NamedQuery_realMySql_aWritingFunctionsEffectIsRolledBack()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ """{"bump":{"sql":"SELECT sqlpoll.bump_counter() AS v"}}""",
+ Tag("Sql/Q/Bump", new JsonObject { ["model"] = "Query", ["query"] = "bump", ["column"] = "v" }));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Q/Bump"], TestContext.Current.CancellationToken))[0];
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToInt64(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(1L);
+
+ (await _my.ScalarAsync("SELECT COUNT(*) FROM sqlpoll.side_effects")).ShouldBe(0L);
+ }
+
+ // ---- helpers ----
+
+ private void SkipUnlessLive()
+ {
+ if (_my.SkipReason is { } reason) Assert.Skip(reason);
+ }
+
+ private Task StartAsync(params RawTagEntry[] tags) => StartAsync(queriesJson: null, tags);
+
+ ///
+ /// Builds and initializes a driver through the PRODUCTION factory (provider: MySql, the connection
+ /// string resolved from the fixture's connectionStringRef environment variable), so the suite
+ /// exercises MySqlConnectorFactory.Instance and the factory's own named-query validation exactly as a
+ /// deployed config would.
+ ///
+ private async Task StartAsync(string? queriesJson, params RawTagEntry[] tags)
+ {
+ var rawTags = new JsonArray();
+ foreach (var tag in tags)
+ {
+ rawTags.Add(new JsonObject
+ {
+ ["rawPath"] = tag.RawPath,
+ ["tagConfig"] = tag.TagConfig,
+ ["writeIdempotent"] = tag.WriteIdempotent,
+ });
+ }
+
+ var config = new JsonObject
+ {
+ ["provider"] = nameof(SqlProvider.MySql),
+ ["connectionStringRef"] = _my.ConnectionStringRef,
+ ["commandTimeout"] = "00:00:10",
+ ["operationTimeout"] = "00:00:15",
+ ["rawTags"] = rawTags,
+ };
+ if (queriesJson is not null) config["queries"] = JsonNode.Parse(queriesJson);
+ var json = config.ToJsonString();
+
+ var driver = SqlDriverFactoryExtensions.CreateInstance("sql-mysql-integration", json);
+ try
+ {
+ await driver.InitializeAsync(json, TestContext.Current.CancellationToken);
+ }
+ catch
+ {
+ await driver.DisposeAsync();
+ throw;
+ }
+
+ return driver;
+ }
+
+ private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
+ {
+ ["model"] = "KeyValue",
+ ["table"] = MySqlFixture.Qualified("tag_values"),
+ ["keyColumn"] = "tag_name",
+ ["keyValue"] = key,
+ ["valueColumn"] = "num_value",
+ ["timestampColumn"] = "sample_ts",
+ });
+
+ private static RawTagEntry Tag(string rawPath, JsonObject config)
+ {
+ config["driver"] = "Sql";
+ return new RawTagEntry(rawPath, config.ToJsonString(), WriteIdempotent: false);
+ }
+}
+
+///
+/// A real, seeded MySQL 8 database (Docker/mysql/docker-compose.yml + seed-mysql.sql).
+/// Env-gated, checked before any socket is touched: nothing opens unless
+/// and are both set. Like the Postgres fixture it
+/// does not seed — the compose stack does, as root, so the suite's own login stays SELECT-only.
+///
+public sealed class MySqlFixture : IAsyncLifetime
+{
+ /// Server endpoint in host:port form (e.g. 127.0.0.1:13306). Absent ⇒ skip.
+ public const string EndpointEnvVar = "SQL_MYSQL_ENDPOINT";
+
+ /// The login. Defaults to .
+ public const string UserEnvVar = "SQL_MYSQL_USER";
+
+ /// The password. Required — never defaulted.
+ public const string PasswordEnvVar = "SQL_MYSQL_PASSWORD";
+
+ /// The SELECT-only login the seed creates.
+ public const string DefaultUser = "otopcua";
+
+ /// MySQL's default port.
+ public const int DefaultPort = 3306;
+
+ /// The database (MySQL's "schema") every seeded object lives in.
+ public const string Schema = "sqlpoll";
+
+ /// A key whose row exists with .
+ public const string PresentKey = "Line1.Speed";
+
+ /// A key whose row exists with a NULL value cell.
+ public const string NullValueKey = "Line1.Temp";
+
+ /// The value seeded for .
+ public const double PresentValue = 42.0;
+
+ /// The source timestamp seeded for , read back as UTC.
+ public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
+
+ private const int ProbeConnectTimeoutSeconds = 5;
+
+ /// Why the suite is skipping, or null when the server is reachable.
+ public string? SkipReason { get; private set; }
+
+ /// The connection string handed to the code under test.
+ public string ConnectionString { get; private set; } = string.Empty;
+
+ ///
+ /// The connectionStringRef the suite authors: the fixture publishes
+ /// under its environment variable for the session, exactly as a deployment provisions one.
+ ///
+ public string ConnectionStringRef { get; } = $"MySqlIt{Guid.NewGuid():N}";
+
+ /// The server, for diagnostics; never carries credentials.
+ public string Server { get; private set; } = string.Empty;
+
+ /// Two-part name of a seeded object.
+ /// The bare table name.
+ /// sqlpoll.table.
+ public static string Qualified(string table) => $"{Schema}.{table}";
+
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ if (ResolveConnectionString() is not { } resolved)
+ {
+ SkipReason =
+ $"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, default " +
+ $"'{DefaultUser}') to run the Sql driver's live MySQL suite. Bring the fixture up from " +
+ "Docker/mysql/docker-compose.yml first. No connection is attempted without these.";
+ return;
+ }
+
+ ConnectionString = resolved;
+ Environment.SetEnvironmentVariable(
+ SqlConnectionStringResolver.EnvironmentVariableFor(ConnectionStringRef), ConnectionString);
+ try
+ {
+ await ScalarAsync(new MySqlDialect().LivenessSql).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ SkipReason =
+ $"MySQL at '{Server}' was not reachable ({ex.GetType().Name}: {ex.Message}). Check " +
+ $"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar} and that the fixture stack is up, or unset them to skip.";
+ }
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ Environment.SetEnvironmentVariable(SqlConnectionStringResolver.EnvironmentVariableFor(ConnectionStringRef), null);
+ return ValueTask.CompletedTask;
+ }
+
+ /// Runs a scalar query as the fixture login, for assertions on database state.
+ /// The statement.
+ /// The scalar, as a 64-bit integer when numeric.
+ public async Task