using Microsoft.Data.Sqlite;
using MySqlConnector;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
///
/// Golden tests for (lmxopcua#753), mirroring
/// rule for rule: the injection boundary (values bound, identifiers backtick-quoted), the rejection
/// discipline, the exact catalog SQL, the end-of-statement LIMIT, and the type map — including the
/// reader-side spellings (INT UNSIGNED, BOOL) that information_schema never reports.
///
public class MySqlDialectTests
{
[Fact]
public void QuoteIdentifier_backtickQuotesAndDoublesAnEmbeddedBacktick()
{
var d = new MySqlDialect();
d.QuoteIdentifier("Speed").ShouldBe("`Speed`");
d.QuoteIdentifier("a`b").ShouldBe("`a``b`");
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
=> new MySqlDialect().QuoteIdentifier("x` ; DROP TABLE users --")
.ShouldBe("`x`` ; DROP TABLE users --`");
[Theory]
[InlineData(0x0000)] // NUL — MySQL forbids it in identifiers
[InlineData(0x0001)]
[InlineData(0x007F)]
[InlineData(0x000A)]
public void QuoteIdentifier_rejectsControlCharacters(int codePoint)
=> Should.Throw(() => new MySqlDialect().QuoteIdentifier("a" + (char)codePoint + "b"));
[Theory]
[InlineData(0x200B)]
[InlineData(0x202E)]
[InlineData(0xFEFF)]
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint)
=> Should.Throw(() => new MySqlDialect().QuoteIdentifier("a" + (char)codePoint + "b"));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("trailing ")] // MySQL refuses a name ending in a space, so it can denote no real object
public void QuoteIdentifier_rejectsBlankAndTrailingSpace(string? ident)
=> Should.Throw(() => new MySqlDialect().QuoteIdentifier(ident!));
[Fact]
public void QuoteIdentifier_acceptsExactly64Characters_andRejects65()
{
var d = new MySqlDialect();
d.QuoteIdentifier(new string('a', 64)).ShouldBe("`" + new string('a', 64) + "`");
Should.Throw(() => d.QuoteIdentifier(new string('a', 65)));
}
[Theory]
[InlineData("tinyint", DriverDataType.Int16)]
[InlineData("TINYINT UNSIGNED", DriverDataType.Int16)]
[InlineData("smallint", DriverDataType.Int16)]
[InlineData("SMALLINT UNSIGNED", DriverDataType.UInt16)]
[InlineData("mediumint", DriverDataType.Int32)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("INT UNSIGNED", DriverDataType.UInt32)]
[InlineData("int(10) unsigned zerofill", DriverDataType.UInt32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("BIGINT UNSIGNED", DriverDataType.UInt64)]
[InlineData("BOOL", DriverDataType.Boolean)]
[InlineData("float", DriverDataType.Float32)]
[InlineData("double", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("varchar", DriverDataType.String)]
[InlineData("longtext", DriverDataType.String)]
[InlineData("enum", DriverDataType.String)]
[InlineData("datetime", DriverDataType.DateTime)]
[InlineData("timestamp", DriverDataType.DateTime)]
[InlineData("date", DriverDataType.DateTime)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new MySqlDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("json")]
[InlineData("blob")]
[InlineData("bit")] // a bit string, deliberately NOT a boolean (as on Postgres)
[InlineData("time")] // a time of day, not a point in time
[InlineData("geometry")]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new MySqlDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
[Fact]
public void CatalogSql_isInformationSchema_parameterized_andAliasesTheColumnsTheConsumersRead()
{
var d = new MySqlDialect();
d.ListSchemasSql.ShouldContain("information_schema.TABLES");
d.ListSchemasSql.ShouldContain("AS TABLE_SCHEMA");
foreach (var system in new[] { "'mysql'", "'information_schema'", "'performance_schema'", "'sys'" })
d.ListSchemasSql.ShouldContain(system);
d.DefaultSchemaSql.ShouldBe("SELECT DATABASE()");
d.ListTablesSql.ShouldContain("AS TABLE_NAME");
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain("AS COLUMN_NAME");
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
// schema is bound before table — the order the catalog loader binds in (and what a positional
// provider would need).
d.ListColumnsSql.IndexOf(d.ParameterMarker("schema"), StringComparison.Ordinal)
.ShouldBeLessThan(d.ListColumnsSql.IndexOf(d.ParameterMarker("table"), StringComparison.Ordinal));
}
[Fact]
public void RowLimits_areLimitN_inTheSuffixPosition()
{
var d = new MySqlDialect();
d.SingleRowLimitPrefix.ShouldBeEmpty();
d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1");
d.RowLimitPrefix(5).ShouldBeEmpty();
d.RowLimitSuffix(5).ShouldBe(" LIMIT 5");
Should.Throw(() => d.RowLimitSuffix(0));
}
[Fact]
public void Dialect_identifiesItselfAsMySql_andExposesTheManagedFactory()
{
var d = new MySqlDialect();
d.Provider.ShouldBe(SqlProvider.MySql);
d.Factory.ShouldBeSameAs(MySqlConnectorFactory.Instance);
d.LivenessSql.ShouldBe("SELECT 1");
d.MaxBindParameters.ShouldBe(65535);
d.ParameterMarker("k0").ShouldBe("@k0");
d.ParameterBindName("k0").ShouldBe("@k0");
}
[Fact]
public void ConfigureCommandAndParameter_leaveAForeignProvidersObjectsUntouched()
{
using var command = new SqliteCommand();
new MySqlDialect().ConfigureCommand(command);
var parameter = command.CreateParameter();
((ISqlDialect)new MySqlDialect()).ConfigureParameter(parameter, "7");
parameter.Value.ShouldBeNull();
}
[Fact]
public void Planner_emitsBacktickQuotedSqlWithTheTrailingLimit()
{
var plan = SqlGroupPlanner.Plan(
[new SqlTagDefinition("A", SqlTagModel.WideRow, "plant.status", ColumnName: "oven_temp",
RowSelectorTopByTimestamp: "ts")],
new MySqlDialect()).ShouldHaveSingleItem();
plan.SqlText.ShouldBe("SELECT `oven_temp` FROM `plant`.`status` ORDER BY `ts` DESC LIMIT 1");
}
}