Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs
T

289 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Golden tests for <see cref="PostgresDialect"/>, mirroring <see cref="SqlServerDialectTests"/> 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 <c>LIMIT 1</c>, and <c>bit</c> being a bit string
/// rather than a boolean — the divergence is pinned here rather than left to reading.
/// </summary>
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\"");
}
/// <summary>STAB-24: a string bind is untyped so Postgres infers it against an integer key column.</summary>
[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);
}
/// <summary>Non-string values keep their natural type, and a foreign parameter type is tolerated.</summary>
[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<ArgumentException>(() => 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<ArgumentException>(
() => 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<ArgumentException>(
() => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed"));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => 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<ArgumentException>(() => d.QuoteIdentifier(new string('a', 64)));
}
/// <summary>
/// <c>NAMEDATALEN - 1</c> is 63 <b>bytes in the database encoding</b>, 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.
/// </summary>
[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<ArgumentException>(() => d.QuoteIdentifier(new string('б', 32)));
}
/// <summary>A non-BMP character is 4 UTF-8 bytes (and 2 UTF-16 code units) — 16 of them fill the limit.</summary>
[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<ArgumentException>(
() => 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");
}
/// <summary>
/// 65535 — the <c>Bind</c> 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".
/// </summary>
[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);
}
}