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