From 63372e757a8871232950eac73438d4604f558dff Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 17:07:05 -0500 Subject: [PATCH] feat(sql): MySql provider over MySqlConnector, one shared provider map (lmxopcua#753) SqlProvider.MySql is now a constructed backend instead of a reserved name. - MySqlDialect (Driver.Sql.Dialect): MySqlConnector 2.6.2 (MIT, fully managed, no vulnerable packages), backtick quoting with MySQL's 64-char / no-trailing- space rules, information_schema catalog SQL with the database as the schema (system databases excluded, aliases pinned), trailing LIMIT, a type map that also reads the reader-side spellings (INT UNSIGNED -> UInt32, BOOL -> Boolean). - SqlDialects: the one provider -> dialect map. The factory, the Test Connect probe and the browse picker each carried their own switch (and drifted once); they now all read this, and their refusals list the supported/reserved sets from it. - AdminUI driver form offers MySql. - Tests: MySqlDialectTests (golden quoting/catalog/type map/planner SQL); the provider-gate tests in factory, probe, browser and form now treat MySql as supported and Odbc as the reserved control. MySqlReadTests + Docker/mysql (throwaway mysql:8.4, SELECT-only login): liveness, KV/wide-row/topByTimestamp, INT keys vs authored text, case folding against case-sensitive table names, catalog rejection, browse walk, factory+probe, named query with a bound parameter, and a writing stored function whose effect the rolled-back transaction discards. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- Directory.Packages.props | 4 + docs/drivers/Sql.md | 31 +- .../SqlDriverBrowser.cs | 23 +- .../MySqlDialect.cs | 180 +++++++ .../SqlDialects.cs | 46 ++ ...B.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj | 2 + .../SqlDriverFactoryExtensions.cs | 28 +- .../SqlDriverProbe.cs | 28 +- .../Shared/Drivers/Forms/SqlDriverForm.razor | 7 +- .../SqlDriverBrowserTests.cs | 11 +- .../Docker/mysql/docker-compose.yml | 65 +++ .../Docker/mysql/seed-mysql.sql | 73 +++ .../MySqlReadTests.cs | 484 ++++++++++++++++++ .../MySqlDialectTests.cs | 161 ++++++ .../SqlDriverFactoryTests.cs | 9 +- .../SqlDriverProbeTests.cs | 2 +- .../Uns/SqlDriverFormContractTests.cs | 5 +- 17 files changed, 1076 insertions(+), 83 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/MySqlDialect.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlDialects.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/docker-compose.yml create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/mysql/seed-mysql.sql create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/MySqlReadTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/MySqlDialectTests.cs 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 ScalarAsync(string sql) + { + var connection = new MySqlDialect().Factory.CreateConnection() + ?? throw new InvalidOperationException("MySqlConnector returned no connection object."); + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = ConnectionString; + await connection.OpenAsync().ConfigureAwait(false); + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + var value = await command.ExecuteScalarAsync().ConfigureAwait(false); + return value is IConvertible and not string ? Convert.ToInt64(value, CultureInfo.InvariantCulture) : value; + } + } + } + + /// + /// Composes the connection string through the dialect's own factory builder (no direct MySqlConnector + /// reference in this project — see the csproj note), or returns null when unconfigured. + /// + private string? ResolveConnectionString() + { + if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null; + if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null; + var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : DefaultUser; + + var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort); + Server = $"{host}:{port}"; + + DbConnectionStringBuilder builder = new MySqlDialect().Factory.CreateConnectionStringBuilder() + ?? throw new InvalidOperationException("MySqlConnector returned no connection-string builder."); + builder["Server"] = host; + builder["Port"] = port; + builder["Database"] = Schema; + builder["User ID"] = user; + builder["Password"] = password; + builder["Connection Timeout"] = ProbeConnectTimeoutSeconds; + return builder.ConnectionString; + } +} + +/// Collection definition so the gate is resolved once per session. +[CollectionDefinition(Name)] +public sealed class MySqlCollection : ICollectionFixture +{ + /// The collection name test classes reference. + public const string Name = "SqlMySqlServer"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/MySqlDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/MySqlDialectTests.cs new file mode 100644 index 000000000..354d8ff67 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/MySqlDialectTests.cs @@ -0,0 +1,161 @@ +using Microsoft.Data.Sqlite; +using MySqlConnector; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Golden tests for (lmxopcua#753), mirroring +/// rule for rule: the injection boundary (values bound, identifiers backtick-quoted), the rejection +/// discipline, the exact catalog SQL, the end-of-statement LIMIT, and the type map — including the +/// reader-side spellings (INT UNSIGNED, BOOL) that information_schema never reports. +/// +public class MySqlDialectTests +{ + [Fact] + public void QuoteIdentifier_backtickQuotesAndDoublesAnEmbeddedBacktick() + { + var d = new MySqlDialect(); + d.QuoteIdentifier("Speed").ShouldBe("`Speed`"); + d.QuoteIdentifier("a`b").ShouldBe("`a``b`"); + } + + [Fact] + public void QuoteIdentifier_neutralisesAClassicInjectionPayload() + => new MySqlDialect().QuoteIdentifier("x` ; DROP TABLE users --") + .ShouldBe("`x`` ; DROP TABLE users --`"); + + [Theory] + [InlineData(0x0000)] // NUL — MySQL forbids it in identifiers + [InlineData(0x0001)] + [InlineData(0x007F)] + [InlineData(0x000A)] + public void QuoteIdentifier_rejectsControlCharacters(int codePoint) + => Should.Throw(() => new MySqlDialect().QuoteIdentifier("a" + (char)codePoint + "b")); + + [Theory] + [InlineData(0x200B)] + [InlineData(0x202E)] + [InlineData(0xFEFF)] + public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint) + => Should.Throw(() => new MySqlDialect().QuoteIdentifier("a" + (char)codePoint + "b")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("trailing ")] // MySQL refuses a name ending in a space, so it can denote no real object + public void QuoteIdentifier_rejectsBlankAndTrailingSpace(string? ident) + => Should.Throw(() => new MySqlDialect().QuoteIdentifier(ident!)); + + [Fact] + public void QuoteIdentifier_acceptsExactly64Characters_andRejects65() + { + var d = new MySqlDialect(); + d.QuoteIdentifier(new string('a', 64)).ShouldBe("`" + new string('a', 64) + "`"); + Should.Throw(() => d.QuoteIdentifier(new string('a', 65))); + } + + [Theory] + [InlineData("tinyint", DriverDataType.Int16)] + [InlineData("TINYINT UNSIGNED", DriverDataType.Int16)] + [InlineData("smallint", DriverDataType.Int16)] + [InlineData("SMALLINT UNSIGNED", DriverDataType.UInt16)] + [InlineData("mediumint", DriverDataType.Int32)] + [InlineData("int", DriverDataType.Int32)] + [InlineData("INT UNSIGNED", DriverDataType.UInt32)] + [InlineData("int(10) unsigned zerofill", DriverDataType.UInt32)] + [InlineData("bigint", DriverDataType.Int64)] + [InlineData("BIGINT UNSIGNED", DriverDataType.UInt64)] + [InlineData("BOOL", DriverDataType.Boolean)] + [InlineData("float", DriverDataType.Float32)] + [InlineData("double", DriverDataType.Float64)] + [InlineData("decimal", DriverDataType.Float64)] + [InlineData("varchar", DriverDataType.String)] + [InlineData("longtext", DriverDataType.String)] + [InlineData("enum", DriverDataType.String)] + [InlineData("datetime", DriverDataType.DateTime)] + [InlineData("timestamp", DriverDataType.DateTime)] + [InlineData("date", DriverDataType.DateTime)] + public void MapColumnType_mapsFamilies(string sql, DriverDataType expected) + => new MySqlDialect().MapColumnType(sql).ShouldBe(expected); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("json")] + [InlineData("blob")] + [InlineData("bit")] // a bit string, deliberately NOT a boolean (as on Postgres) + [InlineData("time")] // a time of day, not a point in time + [InlineData("geometry")] + public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql) + => new MySqlDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String); + + [Fact] + public void CatalogSql_isInformationSchema_parameterized_andAliasesTheColumnsTheConsumersRead() + { + var d = new MySqlDialect(); + d.ListSchemasSql.ShouldContain("information_schema.TABLES"); + d.ListSchemasSql.ShouldContain("AS TABLE_SCHEMA"); + foreach (var system in new[] { "'mysql'", "'information_schema'", "'performance_schema'", "'sys'" }) + d.ListSchemasSql.ShouldContain(system); + d.DefaultSchemaSql.ShouldBe("SELECT DATABASE()"); + + d.ListTablesSql.ShouldContain("AS TABLE_NAME"); + d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); + d.ListColumnsSql.ShouldContain("AS COLUMN_NAME"); + d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema")); + d.ListColumnsSql.ShouldContain(d.ParameterMarker("table")); + // schema is bound before table — the order the catalog loader binds in (and what a positional + // provider would need). + d.ListColumnsSql.IndexOf(d.ParameterMarker("schema"), StringComparison.Ordinal) + .ShouldBeLessThan(d.ListColumnsSql.IndexOf(d.ParameterMarker("table"), StringComparison.Ordinal)); + } + + [Fact] + public void RowLimits_areLimitN_inTheSuffixPosition() + { + var d = new MySqlDialect(); + d.SingleRowLimitPrefix.ShouldBeEmpty(); + d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1"); + d.RowLimitPrefix(5).ShouldBeEmpty(); + d.RowLimitSuffix(5).ShouldBe(" LIMIT 5"); + Should.Throw(() => d.RowLimitSuffix(0)); + } + + [Fact] + public void Dialect_identifiesItselfAsMySql_andExposesTheManagedFactory() + { + var d = new MySqlDialect(); + d.Provider.ShouldBe(SqlProvider.MySql); + d.Factory.ShouldBeSameAs(MySqlConnectorFactory.Instance); + d.LivenessSql.ShouldBe("SELECT 1"); + d.MaxBindParameters.ShouldBe(65535); + d.ParameterMarker("k0").ShouldBe("@k0"); + d.ParameterBindName("k0").ShouldBe("@k0"); + } + + [Fact] + public void ConfigureCommandAndParameter_leaveAForeignProvidersObjectsUntouched() + { + using var command = new SqliteCommand(); + new MySqlDialect().ConfigureCommand(command); + var parameter = command.CreateParameter(); + ((ISqlDialect)new MySqlDialect()).ConfigureParameter(parameter, "7"); + parameter.Value.ShouldBeNull(); + } + + [Fact] + public void Planner_emitsBacktickQuotedSqlWithTheTrailingLimit() + { + var plan = SqlGroupPlanner.Plan( + [new SqlTagDefinition("A", SqlTagModel.WideRow, "plant.status", ColumnName: "oven_temp", + RowSelectorTopByTimestamp: "ts")], + new MySqlDialect()).ShouldHaveSingleItem(); + + plan.SqlText.ShouldBe("SELECT `oven_temp` FROM `plant`.`status` ORDER BY `ts` DESC LIMIT 1"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs index 9d1265de6..c639ce1f5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs @@ -145,7 +145,7 @@ public sealed class SqlDriverFactoryTests // ---- provider gate ---- /// - /// The falsifiability control for the widened gate: is still a + /// The falsifiability control for the widened gate: is still a /// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that /// had quietly become "construct anything" would pass every test below. /// @@ -157,9 +157,9 @@ public sealed class SqlDriverFactoryTests SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( - "d1", $$"""{"provider":"MySql","connectionStringRef":"{{name}}"}""", null)); + "d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null)); - ex.Message.ShouldContain("MySql"); + ex.Message.ShouldContain("Odbc"); ex.Message.ShouldContain("not available in this build"); } @@ -180,6 +180,7 @@ public sealed class SqlDriverFactoryTests ex.Message.ShouldContain(nameof(SqlProvider.SqlServer)); ex.Message.ShouldContain(nameof(SqlProvider.Postgres)); ex.Message.ShouldContain(nameof(SqlProvider.Oracle)); + ex.Message.ShouldContain(nameof(SqlProvider.MySql)); } /// @@ -192,6 +193,7 @@ public sealed class SqlDriverFactoryTests [InlineData("SqlServer", typeof(SqlServerDialect))] [InlineData("Postgres", typeof(PostgresDialect))] [InlineData("Oracle", typeof(OracleDialect))] + [InlineData("MySql", typeof(MySqlDialect))] public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected) { var name = NewRef(); @@ -212,6 +214,7 @@ public sealed class SqlDriverFactoryTests [Theory] [InlineData("Postgres")] [InlineData("Oracle")] + [InlineData("MySql")] public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider) { var name = NewRef(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs index 54235ceea..3a2760627 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs @@ -171,6 +171,7 @@ public sealed class SqlDriverProbeTests [InlineData(SqlProvider.SqlServer)] [InlineData(SqlProvider.Postgres)] [InlineData(SqlProvider.Oracle)] + [InlineData(SqlProvider.MySql)] public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider) => SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider); @@ -179,7 +180,6 @@ public sealed class SqlDriverProbeTests /// rejected. Without this, widening the gate to "return a dialect for anything" would pass. /// [Theory] - [InlineData(SqlProvider.MySql)] [InlineData(SqlProvider.Odbc)] public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider) => Should.Throw(() => SqlDriverProbe.DefaultDialectForTest(provider)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs index 807095d23..697659bbb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs @@ -101,6 +101,7 @@ public sealed class SqlDriverFormContractTests [InlineData(SqlProvider.SqlServer, "SqlServer")] [InlineData(SqlProvider.Postgres, "Postgres")] [InlineData(SqlProvider.Oracle, "Oracle")] + [InlineData(SqlProvider.MySql, "MySql")] public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name) { var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" }; @@ -116,12 +117,10 @@ public sealed class SqlDriverFormContractTests } /// - /// MySql and Odbc stay reserved-and-rejected (design §2), so the dropdown deliberately does not offer - /// them. Nothing in the form model blocks the value — this records the intent so a later "the enum has + /// Odbc stays reserved-and-rejected in this build, so the dropdown deliberately does not offer it. Nothing in the form model blocks the value — this records the intent so a later "the enum has /// five members, the dropdown should too" edit has to argue with a test. /// [Theory] - [InlineData(SqlProvider.MySql)] [InlineData(SqlProvider.Odbc)] public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved) {