using Microsoft.Data.Sqlite; 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 , mirroring rule for /// rule: the same injection boundary (values bound, identifiers quoted), the same rejection discipline, /// and the exact catalog SQL. Where Postgres deliberately differs from T-SQL — double-quote escaping, a /// 63-character identifier ceiling, the end-of-statement LIMIT 1, and bit being a bit string /// rather than a boolean — the divergence is pinned here rather than left to reading. /// public class PostgresDialectTests { [Fact] public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes() { var d = new PostgresDialect(); d.QuoteIdentifier("Speed").ShouldBe("\"Speed\""); // " doubled, per the SQL-standard rule Postgres follows. d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); } /// STAB-24: a string bind is untyped so Postgres infers it against an integer key column. [Fact] public void ConfigureParameter_sendsStringBindsAsUnknownType_soIntegerKeysCompare() { var p = new Npgsql.NpgsqlParameter("@k0", "3") { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text }; new PostgresDialect().ConfigureParameter(p, "3"); p.NpgsqlDbType.ShouldBe(NpgsqlTypes.NpgsqlDbType.Unknown); } /// Non-string values keep their natural type, and a foreign parameter type is tolerated. [Fact] public void ConfigureParameter_leavesNonStringsAndForeignParametersAlone() { var d = new PostgresDialect(); var n = new Npgsql.NpgsqlParameter("@k0", 3) { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Integer }; d.ConfigureParameter(n, 3); n.NpgsqlDbType.ShouldBe(NpgsqlTypes.NpgsqlDbType.Integer); Should.NotThrow(() => d.ConfigureParameter(new SqliteParameter("@k0", "3"), "3")); } [Fact] public void QuoteIdentifier_rejectsControlCharsAndNul() => Should.Throw(() => new PostgresDialect().QuoteIdentifier("x\0y")); // NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. The // code points are spelled numerically so the offending characters never appear raw in this source file. [Theory] [InlineData(0x0001)] // C0 SOH [InlineData(0x001B)] // C0 ESC [InlineData(0x007F)] // DEL [InlineData(0x0085)] // C1 NEL [InlineData(0x0009)] // TAB [InlineData(0x000A)] // LF public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(int codePoint) => Should.Throw( () => new PostgresDialect().QuoteIdentifier("x" + (char)codePoint + "y")); // Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of // these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing // byte-different from the real catalog name (Trojan Source, CVE-2021-42574). [Theory] [InlineData(0x200B)] // zero-width space [InlineData(0x202E)] // right-to-left override [InlineData(0x2066)] // left-to-right isolate [InlineData(0xFEFF)] // BOM / zero-width no-break space [InlineData(0x00AD)] // soft hyphen public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint) => Should.Throw( () => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed")); [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("\t")] public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident) => Should.Throw(() => new PostgresDialect().QuoteIdentifier(ident!)); [Fact] public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote() => new PostgresDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\""); // the 1-char name " // NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it // is a config mistake rather than something to quote and send. [Fact] public void QuoteIdentifier_acceptsExactly63Bytes_andRejects64() { var d = new PostgresDialect(); d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\""); Should.Throw(() => d.QuoteIdentifier(new string('a', 64))); } /// /// NAMEDATALEN - 1 is 63 bytes in the database encoding, not 63 characters — so a /// 32-character Cyrillic name is 64 UTF-8 bytes and the server truncates it to something that names a /// different object. A ceiling measured in UTF-16 code units lets that name straight through, which /// is precisely the silent-truncation failure this rule documents itself as preventing. /// [Fact] public void QuoteIdentifier_measuresTheCeilingInUtf8Bytes_notCharacters() { var d = new PostgresDialect(); // 31 Cyrillic characters = 62 UTF-8 bytes — inside the limit, and well inside a character count. d.QuoteIdentifier(new string('б', 31)).ShouldBe("\"" + new string('б', 31) + "\""); // 32 characters = 64 bytes. Under a character-counting ceiling this passes; the server truncates it. Should.Throw(() => d.QuoteIdentifier(new string('б', 32))); } /// A non-BMP character is 4 UTF-8 bytes (and 2 UTF-16 code units) — 16 of them fill the limit. [Fact] public void QuoteIdentifier_countsASupplementaryCharacterAsItsFourUtf8Bytes() { var d = new PostgresDialect(); // U+1D400 MATHEMATICAL BOLD CAPITAL A, as a surrogate pair. const string a = "\U0001D400"; d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 15))).ShouldNotBeNullOrEmpty(); // 60 bytes Should.Throw( () => d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 16)))); // 64 bytes } [Fact] public void QuoteIdentifier_neutralisesAClassicInjectionPayload() { // A hostile "column name" cannot escape the quotes: the only metacharacter that could is ", and it // is doubled. The result is a single (nonexistent) identifier, never executable SQL. new PostgresDialect().QuoteIdentifier("x\" ; DROP TABLE users --") .ShouldBe("\"x\"\" ; DROP TABLE users --\""); } [Theory] [InlineData("boolean", DriverDataType.Boolean)] [InlineData("smallint", DriverDataType.Int16)] [InlineData("integer", DriverDataType.Int32)] [InlineData("bigint", DriverDataType.Int64)] [InlineData("real", DriverDataType.Float32)] [InlineData("double precision", DriverDataType.Float64)] [InlineData("numeric", DriverDataType.Float64)] [InlineData("text", DriverDataType.String)] [InlineData("character varying", DriverDataType.String)] [InlineData("character", DriverDataType.String)] [InlineData("uuid", DriverDataType.String)] [InlineData("timestamp without time zone", DriverDataType.DateTime)] [InlineData("timestamp with time zone", DriverDataType.DateTime)] [InlineData("date", DriverDataType.DateTime)] public void MapColumnType_mapsFamilies(string sql, DriverDataType expected) => new PostgresDialect().MapColumnType(sql).ShouldBe(expected); [Theory] [InlineData("BOOLEAN", DriverDataType.Boolean)] [InlineData("Double Precision", DriverDataType.Float64)] public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected) => new PostgresDialect().MapColumnType(sql).ShouldBe(expected); [Theory] [InlineData("bytea")] // binary, exactly as SQL Server's varbinary falls back [InlineData("bit")] // a Postgres BIT STRING, deliberately NOT a boolean [InlineData("bit varying")] [InlineData("time without time zone")] // a time of day, not a point in time (mirrors T-SQL `time`) [InlineData("interval")] [InlineData("json")] [InlineData("ARRAY")] [InlineData("USER-DEFINED")] [InlineData("")] [InlineData(null)] public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql) => new PostgresDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String); [Fact] public void CatalogSql_isInformationSchemaAndParameterized() { var d = new PostgresDialect(); d.LivenessSql.ShouldBe("SELECT 1"); d.DefaultSchemaSql.ShouldBe("SELECT current_schema()"); d.ListTablesSql.ShouldContain("information_schema.tables"); d.ListColumnsSql.ShouldContain("information_schema.columns"); d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated d.ListColumnsSql.ShouldContain("@schema"); d.ListColumnsSql.ShouldContain("@table"); } // The markers baked into the catalog SQL text and the ones ParameterMarker hands the loader must be the // same string, or the loader binds a name the statement never mentions. [Fact] public void CatalogSql_markersAgreeWithParameterMarker() { var d = new PostgresDialect(); d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema")); d.ListColumnsSql.ShouldContain(d.ParameterMarker("table")); } [Fact] public void ListSchemasSql_excludesTheSystemSchemas() { var sql = new PostgresDialect().ListSchemasSql; sql.ShouldContain("'pg_catalog'"); sql.ShouldContain("'information_schema'"); // The prefix families are matched with LIKE, where a bare _ is the single-character wildcard — so // they carry an explicit escape. Asserting the escaped spelling is what makes this test able to fail // if someone "tidies" the backslash away and silently widens the exclusion. sql.ShouldContain("'pg\\_toast%' ESCAPE '\\'"); sql.ShouldContain("'pg\\_temp%' ESCAPE '\\'"); } // The loader and the browse session read every catalog column BY ALIAS (DbDataReader.GetOrdinal), and // Postgres folds an unquoted output-column name to lower case. The aliases are therefore quoted, and // this pins the exact ones the consumers ask for. [Fact] public void CatalogSql_projectsTheQuotedUpperCaseAliasesTheConsumersReadByName() { var d = new PostgresDialect(); d.ListSchemasSql.ShouldContain("\"TABLE_SCHEMA\""); d.ListTablesSql.ShouldContain("\"TABLE_NAME\""); d.ListTablesSql.ShouldContain("\"TABLE_TYPE\""); d.ListColumnsSql.ShouldContain("\"COLUMN_NAME\""); d.ListColumnsSql.ShouldContain("\"DATA_TYPE\""); d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\""); } [Fact] public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace() { var d = new PostgresDialect(); // Postgres spells the limit at the END of the statement, the mirror image of T-SQL's TOP 1. The // planner appends the suffix straight onto the finished statement, so the space belongs to it. d.SingleRowLimitPrefix.ShouldBe(""); d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1"); string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"a\"", d.SingleRowLimitSuffix) .ShouldBe("SELECT \"a\" LIMIT 1"); } /// /// 65535 — the Bind message's 16-bit parameter count, a protocol ceiling rather than a server /// setting. Pinned so the planner's chunking has a real number here instead of a special case for /// "this dialect has no meaningful limit". /// [Fact] public void MaxBindParameters_isTheWireProtocolsSixteenBitCount() => new PostgresDialect().MaxBindParameters.ShouldBe(65535); [Fact] public void Dialect_identifiesItselfAsPostgres_andExposesTheAbstractFactory() { var d = new PostgresDialect(); d.Provider.ShouldBe(SqlProvider.Postgres); d.Factory.ShouldBeSameAs(Npgsql.NpgsqlFactory.Instance); // The seam must not leak Npgsql into its signature. typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))! .PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory)); } [Theory] [InlineData("k0")] [InlineData("w")] [InlineData("schema")] public void ParameterMarkerAndBindName_areBothAtPrefixed(string baseName) { var d = new PostgresDialect(); d.ParameterMarker(baseName).ShouldBe("@" + baseName); d.ParameterBindName(baseName).ShouldBe("@" + baseName); } // Npgsql binds by name out of the box, so ConfigureCommand has nothing to do — and it must tolerate a // command that is not an NpgsqlCommand at all (the driver can be pointed at a substituted factory). [Fact] public void ConfigureCommand_leavesAForeignCommandUntouched() { using var command = new SqliteCommand("SELECT 1"); command.Parameters.AddWithValue("@k0", 7); Should.NotThrow(() => new PostgresDialect().ConfigureCommand(command)); command.CommandText.ShouldBe("SELECT 1"); command.Parameters.Count.ShouldBe(1); command.Parameters[0].ParameterName.ShouldBe("@k0"); command.Parameters[0].Value.ShouldBe(7); } }