Files
T
Joseph Doherty 63372e757a 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
2026-11-09 17:30:31 -05:00

162 lines
6.8 KiB
C#

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");
}
}