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:
@@ -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()}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user