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
This commit is contained in:
Joseph Doherty
2026-11-09 17:07:05 -05:00
parent a68593e714
commit 63372e757a
17 changed files with 1076 additions and 83 deletions
@@ -52,13 +52,14 @@ public sealed class SqlDriverBrowserTests
/// member fail here rather than silently browse as SQL Server.
/// </summary>
[Fact]
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
public void DefaultDialectSelector_carriesEveryConstructedProvider()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.MySql).ShouldBeOfType<MySqlDialect>();
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle, SqlProvider.MySql];
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
@@ -188,7 +189,7 @@ public sealed class SqlDriverBrowserTests
// ---- provider availability ----
/// <summary>
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.Odbc"/> is still
/// reserved and must be reported as unavailable, naming itself.
/// </summary>
[Fact]
@@ -198,9 +199,9 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
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");
}
@@ -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"
@@ -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;
@@ -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;
/// <summary>
/// The <c>Sql</c> driver against a <b>real MySQL 8</b> (lmxopcua#753), over the <c>MySqlConnector</c> provider
/// <c>MySqlDialect</c> ships — the MySQL counterpart of <see cref="PostgresReadTests"/>, narrowed the same way
/// to what a dialect can get wrong: liveness, the <c>information_schema</c> catalog SQL (a MySQL database
/// playing the schema), backtick quoting, the trailing <c>LIMIT</c>, 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.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="MySqlFixture"/>.</para>
/// </summary>
[Collection(MySqlCollection.Name)]
public sealed class MySqlReadTests
{
private readonly MySqlFixture _my;
/// <summary>Initializes a new instance of the <see cref="MySqlReadTests"/> class.</summary>
/// <param name="my">The seeded live-server fixture (collection-scoped).</param>
public MySqlReadTests(MySqlFixture my) => _my = my;
/// <summary>Liveness + catalog load succeed for a SELECT-only login, and the endpoint is described credential-free.</summary>
[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);
}
/// <summary>A seeded key-value row round-trips Good, with its <c>datetime(3)</c> source timestamp stamped UTC.</summary>
[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
}
/// <summary>
/// <c>topByTimestamp</c> resolves to the newest row through the dialect's trailing <c>LIMIT 1</c>. The seed
/// makes the newest row neither first-inserted nor lowest-keyed, so losing either the limit or the
/// <c>ORDER BY … DESC</c> yields a wrong value.
/// </summary>
[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);
}
/// <summary>Authored key text compares against an INT key column, and against an INT where column.</summary>
[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);
}
/// <summary>
/// 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 <c>`TAG_VALUES`</c> would name no table: the gate's
/// substitution is what makes mixed-case authoring work.
/// </summary>
[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);
}
/// <summary>A table the login cannot see is rejected by the catalog gate (BadNodeIdUnknown), not queried.</summary>
[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);
}
/// <summary>The AdminUI picker's walk — schemas → tables → columns → attributes — over a real MySQL.</summary>
[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
}
/// <summary>
/// The production factory and Test Connect probe both admit <c>MySql</c> — the provider gate the three
/// switches used to disagree on — and a factory-built driver reads.
/// </summary>
[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 ----
/// <summary>A named query with a bound parameter and a rowKey reads its row; the query bypasses the catalog.</summary>
[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
}
/// <summary>
/// The backstop proven against a real side effect: <c>bump_counter()</c> INSERTs a row and returns 1. A
/// <c>SELECT bump_counter()</c> 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.
/// </summary>
[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<SqlDriver> StartAsync(params RawTagEntry[] tags) => StartAsync(queriesJson: null, tags);
/// <summary>
/// Builds and initializes a driver through the PRODUCTION factory (<c>provider: MySql</c>, the connection
/// string resolved from the fixture's <c>connectionStringRef</c> environment variable), so the suite
/// exercises <c>MySqlConnectorFactory.Instance</c> and the factory's own named-query validation exactly as a
/// deployed config would.
/// </summary>
private async Task<SqlDriver> 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);
}
}
/// <summary>
/// A real, seeded <b>MySQL 8</b> database (<c>Docker/mysql/docker-compose.yml</c> + <c>seed-mysql.sql</c>).
/// <para><b>Env-gated, checked before any socket is touched:</b> nothing opens unless
/// <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> 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.</para>
/// </summary>
public sealed class MySqlFixture : IAsyncLifetime
{
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>127.0.0.1:13306</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_MYSQL_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>.</summary>
public const string UserEnvVar = "SQL_MYSQL_USER";
/// <summary>The password. Required — never defaulted.</summary>
public const string PasswordEnvVar = "SQL_MYSQL_PASSWORD";
/// <summary>The SELECT-only login the seed creates.</summary>
public const string DefaultUser = "otopcua";
/// <summary>MySQL's default port.</summary>
public const int DefaultPort = 3306;
/// <summary>The database (MySQL's "schema") every seeded object lives in.</summary>
public const string Schema = "sqlpoll";
/// <summary>A key whose row exists with <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>A key whose row exists with a NULL value cell.</summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>The source timestamp seeded for <see cref="PresentKey"/>, read back as UTC.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
private const int ProbeConnectTimeoutSeconds = 5;
/// <summary>Why the suite is skipping, or null when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string handed to the code under test.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>
/// The <c>connectionStringRef</c> the suite authors: the fixture publishes <see cref="ConnectionString"/>
/// under its environment variable for the session, exactly as a deployment provisions one.
/// </summary>
public string ConnectionStringRef { get; } = $"MySqlIt{Guid.NewGuid():N}";
/// <summary>The server, for diagnostics; never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object.</summary>
/// <param name="table">The bare table name.</param>
/// <returns><c>sqlpoll.table</c>.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <inheritdoc />
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.";
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
Environment.SetEnvironmentVariable(SqlConnectionStringResolver.EnvironmentVariableFor(ConnectionStringRef), null);
return ValueTask.CompletedTask;
}
/// <summary>Runs a scalar query as the fixture login, for assertions on database state.</summary>
/// <param name="sql">The statement.</param>
/// <returns>The scalar, as a 64-bit integer when numeric.</returns>
public async Task<object?> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>Collection definition so the gate is resolved once per session.</summary>
[CollectionDefinition(Name)]
public sealed class MySqlCollection : ICollectionFixture<MySqlFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlMySqlServer";
}
@@ -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;
/// <summary>
/// Golden tests for <see cref="MySqlDialect"/> (lmxopcua#753), mirroring <see cref="PostgresDialectTests"/>
/// rule for rule: the injection boundary (values bound, identifiers backtick-quoted), the rejection
/// discipline, the exact catalog SQL, the end-of-statement <c>LIMIT</c>, and the type map — including the
/// reader-side spellings (<c>INT UNSIGNED</c>, <c>BOOL</c>) that <c>information_schema</c> never reports.
/// </summary>
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<ArgumentException>(() => new MySqlDialect().QuoteIdentifier("a" + (char)codePoint + "b"));
[Theory]
[InlineData(0x200B)]
[InlineData(0x202E)]
[InlineData(0xFEFF)]
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint)
=> Should.Throw<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentOutOfRangeException>(() => 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");
}
}
@@ -145,7 +145,7 @@ public sealed class SqlDriverFactoryTests
// ---- provider gate ----
/// <summary>
/// The falsifiability control for the widened gate: <see cref="SqlProvider.MySql"/> is still a
/// The falsifiability control for the widened gate: <see cref="SqlProvider.Odbc"/> 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.
/// </summary>
@@ -157,9 +157,9 @@ public sealed class SqlDriverFactoryTests
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => 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));
}
/// <summary>
@@ -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();
@@ -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.
/// </summary>
[Theory]
[InlineData(SqlProvider.MySql)]
[InlineData(SqlProvider.Odbc)]
public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider)
=> Should.Throw<InvalidOperationException>(() => SqlDriverProbe.DefaultDialectForTest(provider))