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:
@@ -114,6 +114,10 @@
|
||||
Microsoft.Data.SqlClient; consumers inherit it transitively. Latest stable, shipping a
|
||||
native lib/net10.0 TFM. -->
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<!-- Fully managed MySQL / MariaDB ADO.NET provider for the Sql poll driver's MySqlDialect
|
||||
(MySqlConnectorFactory.Instance, lmxopcua#753). MIT-licensed and truly async, unlike Oracle's
|
||||
GPL MySql.Data, which is why the design names it. No native client install. -->
|
||||
<PackageVersion Include="MySqlConnector" Version="2.6.2" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
|
||||
+21
-10
@@ -15,11 +15,21 @@ An ordinary SQL database — typically a MES / LIMS / historian staging table th
|
||||
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
|
||||
`SELECT`s on a timer and turns cells into tag values.
|
||||
|
||||
**Three providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres`
|
||||
(`Npgsql`) and `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the
|
||||
`FETCH FIRST` paging needs). `SqlProvider` also declares `MySql` and `Odbc`, but those remain
|
||||
**reserved names, not working backends** — authoring one fails the driver rather than silently
|
||||
falling back. → #753
|
||||
**Four providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres`
|
||||
(`Npgsql`), `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the
|
||||
`FETCH FIRST` paging needs) and `MySql` (`MySqlConnector`, MIT, fully managed — MySQL 8 /
|
||||
MariaDB). `SqlProvider` also declares `Odbc`, which remains a **reserved name, not a working
|
||||
backend** — authoring it fails the driver rather than silently falling back. The provider → dialect
|
||||
map is one shared table (`SqlDialects`) that the factory, the Test Connect probe and the browse
|
||||
picker all read, so the three can no longer disagree about what a build supports.
|
||||
|
||||
MySQL specifics: a MySQL **database is the schema** (`schema.table` = `database.table`; the default
|
||||
schema is the connection's `Database`), identifiers are backtick-quoted (64-character ceiling, no
|
||||
trailing space), and table names are case-sensitive on Linux — the catalog gate's spelling
|
||||
substitution is what lets `PLANT.TAG_VALUES` bind to `plant.tag_values`. `TINYINT(1)` reads as a
|
||||
boolean under MySqlConnector's default `TreatTinyAsBoolean`; `BIT(n)`, `TIME`, `JSON` and binary
|
||||
types infer as `String`. Live gate: `Docker/mysql/docker-compose.yml` + `MySqlReadTests`
|
||||
(`SQL_MYSQL_ENDPOINT` / `SQL_MYSQL_PASSWORD`).
|
||||
|
||||
Two provider-specific things are worth knowing before you author against them:
|
||||
|
||||
@@ -185,7 +195,7 @@ existed in the parser — nothing reads it.)
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `provider` | `SqlServer` | `SqlServer`, `Postgres` or `Oracle`. `MySql` / `Odbc` are reserved and rejected. |
|
||||
| `provider` | `SqlServer` | `SqlServer`, `Postgres`, `Oracle` or `MySql`. `Odbc` is reserved and rejected. |
|
||||
| `connectionStringRef` | — | **A name, never a connection string.** See below. |
|
||||
| `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. |
|
||||
| `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. |
|
||||
@@ -243,7 +253,8 @@ The two classes of authored string are handled differently, and deliberately so:
|
||||
- **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`,
|
||||
`columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated
|
||||
against the live catalog — **`INFORMATION_SCHEMA`** on SQL Server and Postgres, the **`ALL_*`**
|
||||
views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle — and
|
||||
views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle (MySQL also uses
|
||||
`information_schema`) — and
|
||||
dialect-quoted before they reach a command.
|
||||
|
||||
The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt
|
||||
@@ -264,9 +275,9 @@ can commit columns as tags. A committed leaf carries **schema, table and column*
|
||||
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
|
||||
typed editor opened empty.
|
||||
|
||||
Browse works against all three constructed providers; the catalog query is the dialect's own
|
||||
(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
|
||||
themselves excluded — and the `ALL_*` views on Oracle).
|
||||
Browse works against every constructed provider; the catalog query is the dialect's own
|
||||
(`INFORMATION_SCHEMA` on SQL Server, Postgres and MySQL — with `pg_catalog` / `information_schema`
|
||||
and MySQL's `mysql` / `performance_schema` / `sys` excluded — and the `ALL_*` views on Oracle).
|
||||
|
||||
### The committed row selector (Gitea #552)
|
||||
|
||||
|
||||
@@ -122,23 +122,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
ConnectionStringEnvironmentPrefix + connectionStringRef;
|
||||
|
||||
/// <summary>
|
||||
/// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining
|
||||
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
|
||||
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
|
||||
/// unavailable rather than crashing on a null dialect.
|
||||
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: a provider the runtime
|
||||
/// driver constructs but the browser cannot select would leave the operator authoring tags by hand
|
||||
/// against a database the AdminUI refuses to show them.</para>
|
||||
/// The providers this build can browse — exactly the set the runtime driver constructs, through the shared
|
||||
/// <see cref="SqlDialects.TryCreate"/> map. A provider this build does not carry resolves to
|
||||
/// <see langword="null"/> and the caller reports it as unavailable rather than crashing on a null dialect.
|
||||
/// </summary>
|
||||
/// <param name="provider">The authored provider.</param>
|
||||
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => null,
|
||||
};
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => SqlDialects.TryCreate(provider);
|
||||
|
||||
// The configuration is absent / malformed, names a provider this build does not carry, names neither
|
||||
// a connectionStringRef nor a literal, resolves a ref that is not set in the environment, or
|
||||
@@ -159,9 +149,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
// named, and answering it before touching the environment keeps the two failures from masking.
|
||||
var dialect = _dialectSelector(config.Provider)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql provider '{config.Provider}' is not available in this build; browsing supports "
|
||||
+ $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' "
|
||||
+ $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names).");
|
||||
$"Sql provider '{config.Provider}' is not available in this build. "
|
||||
+ SqlDialects.DescribeAvailability());
|
||||
|
||||
var reference = config.ConnectionStringRef;
|
||||
var connectionString = ResolveConnectionString(literal, reference);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using MySqlConnector;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// MySQL / MariaDB dialect (design §2, P5; lmxopcua#753) over the fully managed, MIT-licensed
|
||||
/// <c>MySqlConnector</c> provider — no native client install. Backtick identifier quoting,
|
||||
/// <c>information_schema</c> catalog SQL where a MySQL <em>database</em> plays the role of a schema, an
|
||||
/// end-of-statement <c>LIMIT</c>, and a type map over the names <c>information_schema.COLUMNS.DATA_TYPE</c>
|
||||
/// and <c>MySqlDataReader.GetDataTypeName</c> report.
|
||||
/// <para>Stateless and immutable, so one instance is safely shared by the poll reader and a browse session.</para>
|
||||
/// </summary>
|
||||
public sealed class MySqlDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// MySQL's identifier ceiling — 64 characters for databases, tables and columns. Enforced because a longer
|
||||
/// name can never denote a real object, so it is an authoring mistake to surface, not to quote.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 64;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.MySql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => MySqlConnectorFactory.Instance;
|
||||
|
||||
// MySqlConnector accepts @name (and ?name); this dialect commits to @name so the catalog SQL below and the
|
||||
// planner's markers can never drift apart. The parameter name is the marker verbatim, as on SqlClient.
|
||||
/// <inheritdoc />
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
// No-op: MySqlCommand binds by name with no per-command setup. Deliberately does not cast, so a substituted
|
||||
// or instrumented factory's command passes through untouched.
|
||||
/// <inheritdoc />
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the comment above.
|
||||
}
|
||||
|
||||
// MySQL compares a string bind against a numeric column by converting, so an authored "7" matches an INT
|
||||
// key column with no per-parameter typing (unlike Npgsql). The interface default (no-op) is right.
|
||||
|
||||
// 65535 — the prepared-statement protocol carries the parameter count in two bytes. MySqlConnector
|
||||
// substitutes parameters client-side for unprepared commands and so has no lower ceiling of its own.
|
||||
/// <inheritdoc />
|
||||
public int MaxBindParameters => 65535;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// A MySQL "schema" is a database. Derived from the relations this connection can see (information_schema
|
||||
// shows only objects the principal holds some privilege on), with the server's own databases excluded —
|
||||
// the same "which schemas hold something I may read?" question PostgresDialect answers. Every catalog
|
||||
// column is aliased explicitly so the consumers' GetOrdinal("TABLE_SCHEMA") never depends on how a server
|
||||
// version spells information_schema's own column headers.
|
||||
/// <inheritdoc />
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA AS TABLE_SCHEMA FROM information_schema.TABLES " +
|
||||
"WHERE TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys') " +
|
||||
"ORDER BY TABLE_SCHEMA";
|
||||
|
||||
// DATABASE() is the connection's default database — where an unqualified name resolves. NULL when the
|
||||
// connection string names none; the catalog loader then falls back to the sole visible schema.
|
||||
/// <inheritdoc />
|
||||
public string DefaultSchemaSql => "SELECT DATABASE()";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ListTablesSql =>
|
||||
"SELECT TABLE_NAME AS TABLE_NAME, TABLE_TYPE AS TABLE_TYPE FROM information_schema.TABLES " +
|
||||
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ListColumnsSql =>
|
||||
"SELECT COLUMN_NAME AS COLUMN_NAME, DATA_TYPE AS DATA_TYPE, IS_NULLABLE AS IS_NULLABLE " +
|
||||
"FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table " +
|
||||
"ORDER BY ORDINAL_POSITION";
|
||||
|
||||
// Backtick-quotes one identifier part, doubling an embedded backtick — MySQL's escape rule and the only
|
||||
// character that can end a quoted identifier early. Rejects (ArgumentException, never echoing the value,
|
||||
// matching the sibling dialects): blank; longer than 64 characters; a trailing space (MySQL refuses such a
|
||||
// name, so it cannot denote a real object); any control character (incl. NUL, which MySQL forbids in
|
||||
// identifiers); and any Unicode format character (bidi overrides, zero-width — the Trojan-Source class).
|
||||
/// <inheritdoc />
|
||||
public string QuoteIdentifier(string ident)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ident))
|
||||
throw new ArgumentException("A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
|
||||
|
||||
if (ident.Length > MaxIdentifierLength)
|
||||
throw new ArgumentException(
|
||||
$"A MySQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
|
||||
nameof(ident));
|
||||
|
||||
if (char.IsWhiteSpace(ident[^1]))
|
||||
throw new ArgumentException("A MySQL identifier may not end with a space.", nameof(ident));
|
||||
|
||||
foreach (var ch in ident)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
|
||||
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain Unicode format characters "
|
||||
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
|
||||
}
|
||||
|
||||
return string.Concat("`", ident.Replace("`", "``", StringComparison.Ordinal), "`");
|
||||
}
|
||||
|
||||
// Folds a MySQL type family onto a DriverDataType. Accepts both spellings that reach it: the bare
|
||||
// information_schema DATA_TYPE ("int", "varchar") and the reader's GetDataTypeName, which can carry
|
||||
// "UNSIGNED"/"ZEROFILL" and, for TINYINT(1) under MySqlConnector's default TreatTinyAsBoolean, "BOOL".
|
||||
// A width in parentheses is ignored. Never throws; an unrecognised family (json, blob, geometry, time,
|
||||
// bit) falls back to String, as on the sibling dialects — bit(n) is a bit string, not a boolean, and time is
|
||||
// a time of day rather than a point in time.
|
||||
//
|
||||
// Unsigned integers widen to the next unsigned type the address space can carry; tinyint (−128..127, or
|
||||
// 0..255 unsigned) fits Int16 either way. decimal/numeric collapse to Float64 with the usual precision
|
||||
// caveat.
|
||||
/// <inheritdoc />
|
||||
public DriverDataType MapColumnType(string sqlDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
|
||||
|
||||
var name = sqlDataType.Trim().ToLowerInvariant();
|
||||
var paren = name.IndexOf('(', StringComparison.Ordinal);
|
||||
var close = name.IndexOf(')', StringComparison.Ordinal);
|
||||
if (paren >= 0) name = (name[..paren] + (close > paren ? name[(close + 1)..] : string.Empty)).Trim();
|
||||
|
||||
var unsigned = name.Contains("unsigned", StringComparison.Ordinal);
|
||||
name = name.Replace("unsigned", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("zerofill", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
|
||||
return name switch
|
||||
{
|
||||
"bool" or "boolean" => DriverDataType.Boolean,
|
||||
"tinyint" or "year" => DriverDataType.Int16,
|
||||
"smallint" => unsigned ? DriverDataType.UInt16 : DriverDataType.Int16,
|
||||
"mediumint" or "int" or "integer" => unsigned ? DriverDataType.UInt32 : DriverDataType.Int32,
|
||||
"bigint" => unsigned ? DriverDataType.UInt64 : DriverDataType.Int64,
|
||||
"float" => DriverDataType.Float32,
|
||||
"double" or "double precision" or "real" or "decimal" or "numeric" or "dec" or "fixed"
|
||||
=> DriverDataType.Float64,
|
||||
"char" or "varchar" or "tinytext" or "text" or "mediumtext" or "longtext"
|
||||
or "enum" or "set" => DriverDataType.String,
|
||||
"date" or "datetime" or "timestamp" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The one map from an authored <see cref="SqlProvider"/> to the dialect this build constructs for it —
|
||||
/// shared by the runtime factory, the AdminUI Test Connect probe and the browse picker.
|
||||
/// <para><b>Why one map.</b> Each of those three carried its own switch, and they drifted: when the factory
|
||||
/// and browser were widened to Postgres + Oracle the probe's switch stayed SqlServer-only, so a config that
|
||||
/// deployed and browsed fine failed Test Connect. Every arm returns that provider's OWN dialect — there is
|
||||
/// no fallback arm, because a default that resolved to T-SQL would construct happily and then speak the
|
||||
/// wrong SQL at the server.</para>
|
||||
/// </summary>
|
||||
public static class SqlDialects
|
||||
{
|
||||
/// <summary>The providers this build constructs, in enum order.</summary>
|
||||
public static IReadOnlyList<SqlProvider> Supported { get; } =
|
||||
[.. Enum.GetValues<SqlProvider>().Where(p => TryCreate(p) is not null)];
|
||||
|
||||
/// <summary>Constructs the dialect for <paramref name="provider"/>, or null when this build does not carry it.</summary>
|
||||
/// <param name="provider">The authored provider.</param>
|
||||
/// <returns>A fresh dialect, or null.</returns>
|
||||
public static ISqlDialect? TryCreate(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
SqlProvider.MySql => new MySqlDialect(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// An operator-facing sentence naming the providers this build constructs and the ones it reserves, for
|
||||
/// the "not available in this build" refusals.
|
||||
/// </summary>
|
||||
/// <returns>The sentence.</returns>
|
||||
public static string DescribeAvailability()
|
||||
{
|
||||
var reserved = Enum.GetValues<SqlProvider>().Except(Supported).ToArray();
|
||||
var text = "Supported: " + string.Join(", ", Supported.Select(p => $"'{p}'")) + ".";
|
||||
return reserved.Length == 0
|
||||
? text
|
||||
: text + " Reserved (declared on the enum but not implemented): "
|
||||
+ string.Join(", ", reserved.Select(p => $"'{p}'")) + ".";
|
||||
}
|
||||
}
|
||||
+2
@@ -31,6 +31,8 @@
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
|
||||
<!-- MySqlDialect (lmxopcua#753): fully managed, so it keeps the "no native client" rule. -->
|
||||
<PackageReference Include="MySqlConnector" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
|
||||
|
||||
@@ -206,29 +206,15 @@ public static class SqlDriverFactoryExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the provider seam.
|
||||
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
|
||||
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
|
||||
/// <see cref="SqlProvider.Odbc"/> 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.</para>
|
||||
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
|
||||
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
|
||||
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
|
||||
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
|
||||
/// Selects the provider seam through <see cref="SqlDialects.TryCreate"/> — 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.
|
||||
/// </summary>
|
||||
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()}");
|
||||
|
||||
/// <summary>
|
||||
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
|
||||
|
||||
@@ -188,25 +188,13 @@ public sealed class SqlDriverProbe : IDriverProbe
|
||||
internal static ISqlDialect DefaultDialectForTest(SqlProvider provider) => DefaultDialectFor(provider);
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the production dialect for a provider — <b>the same set
|
||||
/// <see cref="SqlDriverFactoryExtensions"/> constructs</b>, because Test Connect must exercise the
|
||||
/// backend a Deploy would actually use. This build constructs <see cref="SqlProvider.SqlServer"/>,
|
||||
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>;
|
||||
/// <see cref="SqlProvider.MySql"/> and <see cref="SqlProvider.Odbc"/> stay reserved.
|
||||
/// <para><b>Keep this arm-for-arm with the factory's <c>CreateDialect</c>.</b> 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 <see cref="InvalidOperationException"/> — and
|
||||
/// no test noticed, because every probe test injects its dialect through <see cref="ForTest"/>.</para>
|
||||
/// Constructs the production dialect for a provider through <see cref="SqlDialects.TryCreate"/> — <b>the
|
||||
/// same map <see cref="SqlDriverFactoryExtensions"/> uses</b>, 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.)
|
||||
/// </summary>
|
||||
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()}");
|
||||
}
|
||||
|
||||
+4
-3
@@ -18,13 +18,14 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Provider</label>
|
||||
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@* 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). *@
|
||||
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||
<option value="@SqlProvider.Postgres">@SqlProvider.Postgres</option>
|
||||
<option value="@SqlProvider.Oracle">@SqlProvider.Oracle</option>
|
||||
<option value="@SqlProvider.MySql">@SqlProvider.MySql</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.</div>
|
||||
<div class="form-text">SQL Server, PostgreSQL, Oracle and MySQL/MariaDB supported; Odbc reserved.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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"
|
||||
+73
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.MySql)]
|
||||
[InlineData(SqlProvider.Odbc)]
|
||||
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user