using System.Data.Odbc; using System.Text.Json; using System.Text.Json.Nodes; 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) — the ANSI SQL:2008 profile over /// System.Data.Odbc. These run on any host because none of them opens a connection (opening one needs /// the platform's ODBC driver manager plus a vendor driver); the live leg is OdbcReadTests. /// The sharp edge is positional binding. ODBC ignores parameter names and binds ? markers in /// the order parameters are added, so every SQL text this dialect produces must place its markers in the order /// its callers bind — pinned below for the planner's key list and the catalog's schema-then-table pair. /// public class OdbcDialectTests { [Fact] public void Dialect_identifiesItselfAsOdbc_withPositionalMarkers() { var d = new OdbcDialect(); d.Provider.ShouldBe(SqlProvider.Odbc); d.Factory.ShouldBeSameAs(OdbcFactory.Instance); d.ParameterMarker("k0").ShouldBe("?"); d.ParameterMarker("schema").ShouldBe("?"); d.ParameterBindName("k0").ShouldBe("k0"); d.LivenessSql.ShouldBe("SELECT 1"); d.MaxBindParameters.ShouldBe(1000); } [Fact] public void QuoteIdentifier_isAnsiDoubleQuoting() { var d = new OdbcDialect(); d.QuoteIdentifier("Speed").ShouldBe("\"Speed\""); d.QuoteIdentifier("x\" ; DROP TABLE users --").ShouldBe("\"x\"\" ; DROP TABLE users --\""); } [Theory] [InlineData("")] [InlineData(" ")] [InlineData("a\0b")] [InlineData("a‮b")] public void QuoteIdentifier_rejectsWhatCannotBeACatalogName(string ident) => Should.Throw(() => new OdbcDialect().QuoteIdentifier(ident)); [Fact] public void QuoteIdentifier_capsAt128Characters() { new OdbcDialect().QuoteIdentifier(new string('a', 128)).Length.ShouldBe(130); Should.Throw(() => new OdbcDialect().QuoteIdentifier(new string('a', 129))); } [Fact] public void RowLimits_areTheStandardFetchFirst_inTheSuffixPosition() { var d = new OdbcDialect(); d.SingleRowLimitPrefix.ShouldBeEmpty(); d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY"); d.RowLimitPrefix(3).ShouldBeEmpty(); d.RowLimitSuffix(3).ShouldBe(" FETCH FIRST 3 ROWS ONLY"); } [Fact] public void CatalogSql_isInformationSchema_withSchemaBoundBeforeTable() { var d = new OdbcDialect(); d.ListSchemasSql.ShouldContain("INFORMATION_SCHEMA.TABLES"); d.ListSchemasSql.ShouldContain("AS \"TABLE_SCHEMA\""); // The backend is unknown, so the common backends' system schemas are excluded by name (the live gate // caught psqlODBC listing pg_catalog). foreach (var system in new[] { "'INFORMATION_SCHEMA'", "'PG_CATALOG'", "'SYS'", "PG\\_TOAST%" }) d.ListSchemasSql.ShouldContain(system); d.ListTablesSql.Count(ch => ch == '?').ShouldBe(1); // Two positional markers: the first must be the schema, because SqlCatalogLoader and SqlBrowseSession // bind schema first. d.ListColumnsSql.Count(ch => ch == '?').ShouldBe(2); d.ListColumnsSql.IndexOf("TABLE_SCHEMA = ?", StringComparison.Ordinal) .ShouldBeLessThan(d.ListColumnsSql.IndexOf("TABLE_NAME = ?", StringComparison.Ordinal)); } [Theory] [InlineData("integer", DriverDataType.Int32)] [InlineData("bigint", DriverDataType.Int64)] [InlineData("smallint", DriverDataType.Int16)] [InlineData("double precision", DriverDataType.Float64)] [InlineData("numeric(10,2)", DriverDataType.Float64)] [InlineData("real", DriverDataType.Float32)] [InlineData("boolean", DriverDataType.Boolean)] [InlineData("character varying", DriverDataType.String)] [InlineData("timestamp without time zone", DriverDataType.DateTime)] [InlineData("date", DriverDataType.DateTime)] [InlineData("geometry", DriverDataType.String)] [InlineData("", DriverDataType.String)] public void MapColumnType_mapsTheStandardAndCommonSpellings(string sql, DriverDataType expected) => new OdbcDialect().MapColumnType(sql).ShouldBe(expected); [Fact] public void Planner_keyValuePlan_placesOneMarkerPerKey_inBindOrder() { var plan = SqlGroupPlanner.Plan( [ new SqlTagDefinition("A", SqlTagModel.KeyValue, "s.t", KeyColumn: "k", KeyValue: "a", ValueColumn: "v"), new SqlTagDefinition("B", SqlTagModel.KeyValue, "s.t", KeyColumn: "k", KeyValue: "b", ValueColumn: "v"), ], new OdbcDialect()).ShouldHaveSingleItem(); plan.SqlText.ShouldBe("SELECT \"k\", \"v\" FROM \"s\".\"t\" WHERE \"k\" IN (?, ?) FETCH FIRST 5 ROWS ONLY"); plan.Parameters.ShouldBe(["a", "b"]); // positional: value i binds marker i } [Fact] public void Factory_aPositionalNamedQuery_mustCarryOneMarkerPerParameter() { SqlDriverFactoryExtensions.BuildOptions( Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = ? AND y = '?' AND z = ?","params":{"x":1,"z":2}}}"""), "d1", new OdbcDialect()) .Queries["q"].ParameterNames.ShouldBe(["x", "z"]); // the '?' inside a literal is not a marker Should.Throw(() => SqlDriverFactoryExtensions.BuildOptions( Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = ?","params":{"x":1,"z":2}}}"""), "d1", new OdbcDialect())) .Message.ShouldContain("positional"); } private static SqlDriverConfigDto Dto(string queriesJson) => JsonSerializer.Deserialize( new JsonObject { ["connectionStringRef"] = "x", ["queries"] = JsonNode.Parse(queriesJson) }.ToJsonString(), SqlDriverFactoryExtensions.JsonOptionsForTest)!; }