71881163df
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
289 lines
13 KiB
C#
289 lines
13 KiB
C#
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);
|
||
}
|
||
}
|